/*
** Module   : Harness.java
** Abstract : test execution master driver 
**
** Copyright (c) 2008-2021, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 GES 20090126 Test execution master driver.
** 002 CA  20130410 Added support to read variables values and host/port/userid/password from 
**                  command line arguments. If the variable/setting can't be read from the 
**                  arguments or from the console, read it from standard input.
**                  If the testing has failed, exit with the ExitCode.FAILED code.
** 003 GES 20161017 Avoid NPE and improve logging of non-testing failure case.
** 004 CA  20210827 Refactored to allow other test types, beside terminal (which can be executed from the same 
**                  test suite).  Added SOAP and REST web service tests.
** 005 CA  20210906 Allow override via command line arguments, for: web endpoint, variable initial values and 
**                  oauth1 parameters. 
*/

/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
**
** You should have received a copy of the GNU Affero General Public License
** along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/

package com.goldencode.harness;

import java.io.*;
import java.util.*;
import java.util.function.*;

import com.goldencode.harness.test.*;
import com.goldencode.util.*;

/**
 * Test execution master driver which provides a command line interface. See
 * {@link #main} for syntax. 
 */
public class Harness
{
   /** Format specification for summary output. */
   private static final String SUMMARY_SPEC = "%s Status = %s.\nGenerating " +
                                              "HTML reports... ";
                                      
   /** Format specification for delegated variable prompts. */
   private static final String PROMPT_SPEC = "%s (variable '%s', ENTER for " +
                                             "current value '%s') : ";
                                              
   /** Format specification for delegated secret variable prompts. */
   private static final String SECRET_SPEC = "%s (variable '%s', type '%s', " +
                                             "ENTER for no change) : ";
   
   /** Format specification for the uninitialized variable message. */
   private static final String UNINIT_VAR_SPEC = "Variable '%s' is uninitialized.";
                                              
   /** Syntax help text. */
   private final static String[] helptxt;
   
   static
   {
      ArrayList<String> array = new ArrayList<String>();
      
      String[] ht = new String[]
      {
         "------------------Automated TTY Test Harness-------------------",
         "",
         "Syntax:",
         "",
         "java com.goldencode.harness.Harness [options] <test_plan_filename>",
         "",
         "Options:",
         "",
         "-?                    = display this help screen",
         "-b <path_list>        = specify a default set of baseline search",
         "                        paths; <path_list> is a list of one or more",
         "                        paths separated by the platform specific",
         "                        path separator character (e.g. on UNIX or",
         "                        Linux this would be the colon ':')",
         "-e                    = force separate host/port/userid/password",
         "                        prompts for each target system (the default",
         "                        is to only prompt once and to use the same",
         "                        responses for each of the target definitions",
         "                        which don't have the values defined",
         "-t <path_list>        = specify a default set of test/include search",
         "                        paths; <path_list> has the same format as",
         "                        the -b option",
         "-v <var=value>        = specify values for variables which otherwise",
         "                        would need to be read from the console",
         "-h <host>             = specify the host to connect",
         "-p <port>             = specify the port to connect",
         "-u <userid>           = specify the userid used to connect",
         "-w <password>         = specify the password used to connect",
         "                        WARNING! Setting the password in the command",
         "                        line will expose it in the process list.",
         "                        Use this option with care, as it can pose",
         "                        security problems.",
         "",
         "Parameters:",
         "",
         "test_plan_filename    = specific test plan to run (this is the",
         "                        relative or absolute filename of the XML",
         "                        test plan definition)",
         "",
         "Exit Codes:",
         ""
      };
      
      for (String next : ht)
      {
         array.add(next);
      }
      
      for (ExitCode code : ExitCode.values())
      {
         array.add(String.format(" %25s = %d",
                                 code.toString(),
                                 code.getCode()));
      }
      
      helptxt = array.toArray(new String[0]);
   }
   
   /**
    * Print syntax on <code>STDOUT</code>.
    * <p>
    * <b>This method does NOT return! It calls <code>System.exit</code>
    * with the given exit code.</b>
    *
    * @param    rc
    *           The exit code to use with <code>System.exit</code>.
    * @param    extra
    *           Other details of a failure.
    */
   public static void syntax(ExitCode rc, String extra)
   {
      if (rc != ExitCode.PASSED)
      {
         System.err.println(rc.getDescription());
         
         if (extra != null)
         {
            System.err.println(extra);
         }
         
         System.err.println();
      }
      
      if (helptxt != null)
      {
         for (int i = 0; i < helptxt.length; i++)
         {
            System.err.println(helptxt[i]);
         }
      }
      
      System.exit(rc.getCode());
   }
   
   /**
    * Read a session setting from the argument list, console or standard input, in this order.
    * When reading from standard input, no helper text will be shown.
    * 
    * @param    console
    *           The interactive console to prompt for the session setting. May be 
    *           <code>null</code> if a console can't be established.
    * @param    reader
    *           A reader linked to the standard input. Will be <code>null</code> if the console is
    *           not null.
    * @param    varVals
    *           A map containing variable values set in the command line arguments.
    * @param    vname
    *           The variable's name.
    * @param    txt
    *           The text to be shown when prompting for a value using the console. 
    * @param    secret
    *           <code>true</code> when the read value must not echo to the terminal (in case of
    *           passwords or other such variables).
    * 
    * @return   The read value for the given variable.
    */
   private static String readSessionSetting(Console             console,
                                            BufferedReader      reader,
                                            Map<String, String> varVals,
                                            String              vname,
                                            String              txt,
                                            boolean             secret)
   {
      String val = null;
      if (varVals.containsKey(vname))
      {
         val = varVals.get(vname);
      }
      else if (console != null)
      {
         if (secret)
         {
            char[] sval = console.readPassword(txt);
            if (sval != null && sval.length > 0)
            {
               val = new String(sval);
            }
         }
         else
         {
            val = console.readLine(txt);
         }
      }
      else
      {
         try
         {
            // no output is needed on STDOUT, as console will be null only when an interactive 
            // console can't be initialized (i.e. STDOUT is redirected)
            val = reader.readLine();
         }
         catch (IOException e)
         {
            final String spec = "Error reading %s: %s";
            syntax(ExitCode.VARIABLE_READ_ERROR, String.format(spec, vname, e.getMessage()));
         }
      }
      
      return val;
   }

   /**
    * Test plan execution driver.
    *
    * @param    testPlan
    *           Test plan to execute.
    * @param    rc
    *           Object in which to store the details of non-testing failures.
    */
   public void execute(TestPlan testPlan, FailureNotification rc)
   {
      try
      {
         if (testPlan == null)
         {
            rc.setResult(ExitCode.MISSING_CONTENT);
            rc.setDetails("No test plan specified.");
            return;
         }
         
         // notify the user that processing is starting
         System.out.print("\nRunning test plan... ");
         
         // run the tests
         testPlan.execute(rc);
         
         if (rc.isFailed())
         {
            // details are already logged
            return;
         }
         
         // output a summary of the results to the console
         System.out.print(String.format(SUMMARY_SPEC,
                                        completionMsg(testPlan.getElapsed()),
                                        testPlan.getStatus().toString()));
                                                 
         long start = System.currentTimeMillis();
         
         // generate HTML reports for the results
         ReportGenerator reports = new ReportGenerator(testPlan, rc);
         reports.generate();
         
         long end = System.currentTimeMillis();
         
         // notify that report generation is complete
         System.out.println(completionMsg(end - start));
         
         if (rc.isFailed())
         {
            // details are already logged
            return;
         }
         
         // passed
         rc.setResult(null);
      }
      
      catch (Exception excp)
      {
         rc.setResult(ExitCode.UNKNOWN_FAILURE);
         rc.setDetails(LogHelper.dumpThrowable(excp));
      }
   }
   
   /**
    * Generate a completion message that describes the length of time in
    * human readable terms.
    *
    * @param    elapsed
    *           The number of milliseconds of elapsed time.
    *
    * @return   The completion message.
    */
   private String completionMsg(long elapsed)
   {
      StringBuilder sb = new StringBuilder();
      
      sb.append("Completed in ");
      
      long days   = elapsed / 86400000;
      long remain = elapsed % 86400000;
      
      if (days > 0)
      {
         sb.append(String.format("%d days, ", days));
      }
      
      long hours = remain / 3600000;
      remain = remain % 3600000;
      
      if (days > 0 || hours > 0)
      {
         sb.append(String.format("%d hours, ", hours));
      }
      
      long minutes = remain / 60000;
      remain = remain % 60000;
      
      if (days > 0 || hours > 0 || minutes > 0)
      {
         sb.append(String.format("%d minutes and ", minutes));
      }
      
      double seconds = remain / 1000.0D;
      sb.append(String.format("%.3f seconds.", seconds));
      
      return sb.toString();
   }
   
   /**
    * Read <code>key=value</code> pairs until a new option is found or the second-to-last index is reached.
    * 
    * @param    args
    *           The command-line arguments.
    * @param    idx
    *           Current index in the arguments array.
    * @param    c
    *           The consumer to process the pair.
    *           
    * @return   The new index.
    */
   private static int readPairs(String[] args, int idx, BiConsumer<String, String> c)
   {
      while (!args[idx].startsWith("-") && idx < args.length - 1)
      {
         String pair = args[idx++];
         int eqIdx = pair.indexOf('=');
         if (eqIdx < 0)
         {
            final String spec = "The value for '%s' is not set.";
            syntax(ExitCode.MISSING_OPTION_DATA, String.format(spec, pair));
         }
         else if (eqIdx == 0)
         {
            final String spec = "The '%s' data does not contain a name.";
            syntax(ExitCode.MISSING_OPTION_DATA, String.format(spec, pair));
         }
         
         String name = pair.substring(0, eqIdx);
         String value = pair.substring(eqIdx + 1);

         c.accept(name, value);
      }
      
      return idx - 1;
   }
   
   /**
    * Simple command-line interface to invoke the harness.
    * <pre>
    * Syntax:
    * 
    * java com.goldencode.harness.Harness [options] &lt;test_plan_filename&gt;
    *
    * Options:
    *
    * -?                    = display this help screen
    * -b &lt;path_list&gt;        = specify a default set of baseline search
    *                         paths; &lt;path_list&gt; is a list of one or more
    *                         paths separated by the platform specific
    *                         path separator character (e.g. on UNIX or
    *                         Linux this would be the colon ':')
    * -e                    = force separate host/port/userid/password prompts
    *                         for each target system (the default is to only
    *                         prompt once and to use the same responses
    *                         for each of the target definitions which
    *                         don't have the values defined
    * -t &lt;path_list&gt;        = specify a default set of test/include search
    *                         paths; &lt;path_list&gt; has the same format as
    *                         the -b option
    * -v &lt;var=value&gt;        = specify values for variables which otherwise
    *                         would need to be read from the console
    * -h &lt;host&gt;             = specify the host to connect
    * -p &lt;port&gt;             = specify the port to connect
    * -u &lt;userid&gt;           = specify the userid used to connect
    * -w &lt;password&gt;         = specify the password used to connect
    *                         WARNING! Setting the password in the command
    *                         line will expose it in the process list.
    *                         Use this option with care, as it can pose
    *                         security problems.
    * -n &lt;endpoint&gt;         = the override endpoint for web tests
    * -oauth1 &lt;param=value&gt; = list of override parameters for oauth1 authorization (web tests only)
    * -o &lt;var=value&gt;        = list of override variable initialization values
    * 
    * Parameters:
    *
    * test_plan_filename    = specific test plan to run (this is the
    *                         relative or absolute filename of the XML
    *                         test plan definition)
    * </pre>
    * <p>
    * See {@link ExitCode#getCode} for the list of possible exit codes which
    * can be seen from <code>System.exit</code>.
    *
    * @param    args
    *           Command line arguments.
    */
   public static void main(String[] args)
   {
      try
      {
         int     idx       = 0;
         boolean explicit  = false;
         String  basepaths = null;
         String  testpaths = null;
         
         if (args.length < 1)
         {
            // doesn't return
            syntax(ExitCode.MISSING_FILENAME, "A test plan name is required.");
         }
         Map<String, String> varVals = new HashMap<String, String>();
         
         // process options
         while (idx < args.length && args[idx].charAt(0) == '-')
         {
            if (args[idx].length() > 1)
            {
               String opt  = args[idx];
               opt = opt.substring(1); // skip the '-' char
               
               switch (opt)
               {
                  case "?":
                     // doesn't return
                     syntax(ExitCode.PASSED, null);
                     break;
                  case "b":
                     if (idx == args.length - 1)
                     {
                        syntax(ExitCode.MISSING_OPTION_DATA, null);
                     }
                     basepaths = args[++idx];
                     break;
                  case "e":
                     explicit = true;
                     break;
                  case "t":
                     if (idx == args.length - 1)
                     {
                        syntax(ExitCode.MISSING_OPTION_DATA, null);
                     }
                     testpaths = args[++idx];
                     break;
                  case "h":
                     if (idx == args.length - 1)
                     {
                        syntax(ExitCode.MISSING_OPTION_DATA, null);
                     }
                     varVals.put("host", args[++idx]);
                     break;
                  case "p":
                     if (idx == args.length - 1)
                     {
                        syntax(ExitCode.MISSING_OPTION_DATA, null);
                     }
                     varVals.put("port", args[++idx]);
                     break;
                  case "u":
                     if (idx == args.length - 1)
                     {
                        syntax(ExitCode.MISSING_OPTION_DATA, null);
                     }
                     varVals.put("userid", args[++idx]);
                     break;
                  case "w":
                     if (idx == args.length - 1)
                     {
                        syntax(ExitCode.MISSING_OPTION_DATA, null);
                     }
                     varVals.put("password", args[++idx]);
                     break;
                  case "v":
                     if (idx == args.length - 1)
                     {
                        syntax(ExitCode.MISSING_OPTION_DATA, null);
                     }

                     String pair = args[++idx];
                     int eqIdx = pair.indexOf('=');
                     if (eqIdx < 0)
                     {
                        final String spec = "The value for variable '%s' is not set.";
                        syntax(ExitCode.MISSING_OPTION_DATA, String.format(spec, pair));
                     }
                     else if (eqIdx == 0)
                     {
                        final String spec = "The '%s' data does not contain a variable name.";
                        syntax(ExitCode.MISSING_OPTION_DATA, String.format(spec, pair));
                     }
                     
                     String var = pair.substring(0, eqIdx);
                     String value = pair.substring(eqIdx + 1);
                     varVals.put(var, value);
                     break;
                  case "n":
                     if (idx == args.length - 1)
                     {
                        syntax(ExitCode.MISSING_OPTION_DATA, null);
                     }
                     WebSessionSettings.overrideEndpoint = args[++idx];
                     break;
                  case "o":
                     if (idx == args.length - 1)
                     {
                        syntax(ExitCode.MISSING_OPTION_DATA, null);
                     }
                     
                     idx = readPairs(args, idx + 1, (k, v) -> Variable.overrideVariables.put(k, v));
                     break;
                  case "oauth1":
                     if (idx == args.length - 1)
                     {
                        syntax(ExitCode.MISSING_OPTION_DATA, null);
                     }
                     idx = readPairs(args, idx + 1, (k, v) -> OAuth1.overrideParameters.put(k, v));
                     break;
                  default:
                     syntax(ExitCode.INVALID_OPTION,
                            String.format("Unknown option '%s'.", args[idx]));
                     break;
               }
            }
            
            idx++;
         }
         
         if (idx == args.length)
         {
            // doesn't return
            syntax(ExitCode.MISSING_FILENAME, "A test plan name is required.");
         }
         
         FailureNotification rc = new FailureNotification();
         
         TestPlan testPlan = TestPlanFactory.read(args[idx],
                                                  testpaths,
                                                  basepaths,
                                                  rc);
         
         if (testPlan == null)
         {
            // doesn't return
            syntax(rc.getResult(), rc.getDetails());
         }
         
         Console console = System.console();
         BufferedReader reader = null;
         if (console == null)
         {
            reader = new BufferedReader(new InputStreamReader(System.in));
         }

         // prompt for data as needed (vars are declared here so they can be read only once)
         // terminal settings
         String            user    = null;
         String            pw      = null;
         String            host    = null;
         int               port    = -1;
         // web settings
         String endpoint = WebSessionSettings.overrideEndpoint;
         
         List<TestSet>     sets    = testPlan.getSets();
         Iterator<TestSet> iter    = sets.iterator();
            
         // if we are missing any userid or password data, prompt for or get the value from the 
         // corresponding option, it if possible
         // fail if the variable is not initialized from the default value, console or standard
         // input
         while (iter.hasNext())
         {
            TestSet                      set  = iter.next();
            Map<String, SessionSettings> cfgs = set.getCfgs();
            if (cfgs != null)
            {
               for (SessionSettings acfg : cfgs.values())
               {
                  String prompts = acfg.prompts == null ? "" : acfg.prompts;

                  if (acfg instanceof TerminalSessionSettings)
                  {
                     TerminalSessionSettings cfg = (TerminalSessionSettings) acfg;
                     
                     if (cfg.host == null       ||
                         cfg.host.length() == 0 ||
                         prompts.indexOf("host") != -1)
                     {
                        if (explicit || host == null)
                        {
                           String txt = String.format("Host     (%s) : ", cfg.host);
                           host = readSessionSetting(console, reader, varVals, "host", txt, false);
                        }
                        
                        if (host != null && host.length() > 0)
                        {
                           cfg.host = host;
                        }
                        else if (cfg.host == null || cfg.host.length() == 0)
                        {
                           // this var is not set
                           syntax(ExitCode.UNINITIALIZED_VARIABLE, String.format(UNINIT_VAR_SPEC, "host"));
                        }
                     }
                     
                     if (cfg.port == -1 || prompts.indexOf("port") != -1)
                     {
                        if (explicit || port == -1)
                        {
                           String txt = String.format("Port     (%s) : ", cfg.host);
                           String pt = readSessionSetting(console, reader, varVals, "port", txt, false);
                           
                           if (pt.length() > 0)
                           {
                              port = Integer.parseInt(pt);
                           }
                        }
                        
                        if (port != -1)
                        {
                           cfg.port = port;
                        }
                        else if (cfg.port == -1)
                        {
                           // this var is not set
                           syntax(ExitCode.UNINITIALIZED_VARIABLE, String.format(UNINIT_VAR_SPEC, "port"));
                        }
                     }
                     
                     if (cfg.userid == null       ||
                         cfg.userid.length() == 0 ||
                         prompts.indexOf("userid") != -1)
                     {
                        if (explicit || user == null)
                        {
                           String txt = String.format("Userid   (%s) : ", cfg.host);
                           user = readSessionSetting(console, reader, varVals, "userid", txt, false);
                        }
                        
                        if (user != null && user.length() > 0)
                        {
                           cfg.userid = user;
                        }
                        else if (cfg.userid == null || cfg.userid.length() == 0)
                        {
                           // this var is not set
                           syntax(ExitCode.UNINITIALIZED_VARIABLE, 
                                  String.format(UNINIT_VAR_SPEC, "userid"));
                        }
                     }
                     
                     if (cfg.password == null        ||
                         cfg.password.length() == 0  ||
                         prompts.indexOf("password") != -1)
                     {
                        if (explicit || pw == null)
                        {
                           String txt = String.format("Password (%s) : ", cfg.host);
                           pw = readSessionSetting(console, reader, varVals, "password", txt, true);
                        }
                        
                        if (pw != null && pw.length() > 0)
                        {
                           cfg.password = pw;
                        }
                        else if (cfg.password == null || cfg.password.length() == 0)
                        {
                           // this var is not set
                           syntax(ExitCode.UNINITIALIZED_VARIABLE, 
                                  String.format(UNINIT_VAR_SPEC, "password"));
                        }
                     }
                  }
                  else if (acfg instanceof WebSessionSettings)
                  {
                     WebSessionSettings cfg = (WebSessionSettings) acfg;
                     
                     if (cfg.endpoint == null        ||
                         cfg.endpoint.length() == 0  ||
                         prompts.indexOf("endpoint") != -1)
                     {
                        if (explicit || endpoint == null)
                        {
                           String txt = String.format("Endpoint (%s) : ", cfg.endpoint);
                           endpoint = readSessionSetting(console, reader, varVals, "endpoint", txt, false);
                        }
                        
                        if (endpoint != null && endpoint.length() > 0)
                        {
                           cfg.endpoint = endpoint;
                        }
                        else if (cfg.endpoint == null || cfg.endpoint.length() == 0)
                        {
                           // this var is not set
                           syntax(ExitCode.UNINITIALIZED_VARIABLE, 
                                  String.format(UNINIT_VAR_SPEC, "endpoint"));
                        }
                     }
                  }
               }
            }
         }
         
         ResourcePool pool    = testPlan.getResourcePool();
         Prompt[]     prompts = pool.getPrompts();
         
         // prompt to optionally override initial values for variables, but only if the variable
         // is not set via an -v option.
         // if the variable ends uninitialized (from the -v option, prompt or its default value),
         // fail with an error message.
         if (prompts != null)
         {
            for (int z = 0; z < prompts.length; z++)
            {
               if (z == 0 && console != null)
               {
                  console.printf("\n");
               }
               
               Variable var    = prompts[z].getVar();
               String   val    = null;
               String   vname  = var.getName();
               Object   vval   = var.getValue();

               if (varVals.containsKey(vname))
               {
                  val = varVals.get(vname);
               }
               else
               {
                  boolean  secret = prompts[z].isSecret();
                  String   vdescr = prompts[z].getDescr();
                  String   vtype  = var.getTypeName();
                  String   vcurr  = (vval == null) ? "null" : vval.toString();
                  String   txt    = null;
                  
                  if (secret)
                  {
                     txt = String.format(SECRET_SPEC, vdescr, vname, vtype);
                     char[] data = null;
                     if (console != null)
                     {
                        data = console.readPassword(txt);
                        if (data != null)
                           val = new String(data);
                     }
                     else
                     {
                        // no output is needed on STDOUT, as console will be null only when an
                        // interactive console can't be initialized (i.e. STDOUT is redirected)
                        val = reader.readLine();
                     }
                  }
                  else
                  {
                     txt = String.format(PROMPT_SPEC, vdescr, vname, vcurr);
                     if (console != null)
                     {
                        val = console.readLine(txt);
                     }
                     else
                     {
                        // no output is needed on STDOUT, as console will be null only when an
                        // interactive console can't be initialized (i.e. STDOUT is redirected)
                        val = reader.readLine();
                     }
                  }
               }
               
               if (val != null && val.length() > 0)
               {
                  var.fromString(val);
               }
               else if (vval != null && vval.toString().length() > 0)
               {
                  // set from the default value
                  var.fromString(vval.toString());
               }
               else
               {
                  // could not set this variable
                  syntax(ExitCode.UNINITIALIZED_VARIABLE, String.format(UNINIT_VAR_SPEC, vname));
               }
            }
         }
         
         rc = new FailureNotification();
         
         Harness harness = new Harness();
         harness.execute(testPlan, rc);
         
         if (rc.isFailed())
         {
            ExitCode code = rc.getResult();
            String   dets = rc.getDetails();
            
            String err = String.format("Non-testing errors (%s, complete == %b) occured " +
                                       "during test execution:\n%s",
                                       code == null ? "NO EXIT CODE" : code.toString(),
                                       rc.isComplete(),
                                       dets == null ? "MISSING DETAILS" : dets);
            
            System.err.println(err);
            System.exit(code == null ? -999 : code.getCode());
         }
         
         // exit with a proper code, depending on the result (failed or passed)
         System.exit(testPlan.getStatus() == Status.PASSED ? ExitCode.PASSED.getCode()
                                                           : ExitCode.FAILED.getCode());
      }
      
      catch (Exception excp)
      {
         excp.printStackTrace(System.err);
         System.exit(-1000);
      }
   }
}
