/*
** Module   : Terminal.java
** Abstract : vt320 terminal subclass that adds the ability to capture a
**            screen-shot
**
** Copyright (c) 2008-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 GES 20081218 vt320 terminal subclass that adds screen access and the
**                  ability to process/send keys.
** 002 CA  20130428 Added a minimum 50ms delay for each key, during SEND-TEXT.
** 003 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.
** 004 TJD 20220817 Experimental removal of wait loop from screen polling implemented for performance test mode
** 005 TJD 20220818 Screen update processing optimization, bad unit for waitForTimeout
** 006 TJD 20220818 SendText drain mode optimization
** 007 TJD 20220918 SendText drain mode optimization - shared variable needs to be volatile
** 008 RFB 20230111 Added finer logging which helps with debug. Also removed a return value in a comment
**                  to fix javadoc (plus review).
** 009 TJD 20230119 Implemented trace log level
** 010 TJD 20240201 Benchmark mode enhancements
*/

/*
** 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.terminal;

import java.io.*;
import java.util.*;
import de.mud.jta.*;
import de.mud.terminal.*;
import java.awt.event.KeyEvent;
import com.goldencode.harness.*;
import com.goldencode.util.LogHelper;

/**
 * vt320 terminal subclass that adds the ability to capture a screen-shot.
 */
public class Terminal
extends vt320
{
   /** Size of chunks read from incoming stream from the data source. */
   private final static int BUFFER_SIZE = 4096;
   
   /** Character set to use for translation from bytes into chars. */
   private final static String ENCODING = "ISO-8859-1";
   
   /** Allows conversion of symbolic key names into a key code. */
   private final static Map<String, Integer> keycodes =
      new HashMap<String, Integer>();
   
   /** Allows conversion of CTRL+key into the corresponding control codes. */
   private final static Map<Character, Integer> ctrlCodes =
      new HashMap<Character, Integer>();
      
   /** Data source/sink that provides the communications session. */
   private FilterPlugin session = null;
   
   /** Copy of the screen at the last redraw. */
   private char[][] screen = null;
   
   /** Last not empty copy of the screen at the last redraw. */
   private char[][] lastValidScreen;
   
   /** Lock to synchronize access to the screen buffer. */
   private Object lock = new Object();
   
   /** Counter for how many bytes have been read since last reset. */
   volatile private int drain = 0;
   
   static
   {
      keycodes.put("VK_F1",         KeyEvent.VK_F1);
      keycodes.put("VK_F2",         KeyEvent.VK_F2);
      keycodes.put("VK_F3",         KeyEvent.VK_F3);
      keycodes.put("VK_F4",         KeyEvent.VK_F4);
      keycodes.put("VK_F5",         KeyEvent.VK_F5);
      keycodes.put("VK_F6",         KeyEvent.VK_F6);
      keycodes.put("VK_F7",         KeyEvent.VK_F7);
      keycodes.put("VK_F8",         KeyEvent.VK_F8);
      keycodes.put("VK_F9",         KeyEvent.VK_F9);
      keycodes.put("VK_F10",        KeyEvent.VK_F10);
      keycodes.put("VK_F11",        KeyEvent.VK_F11);
      keycodes.put("VK_F12",        KeyEvent.VK_F12);
      keycodes.put("VK_UP",         KeyEvent.VK_UP);
      keycodes.put("VK_DOWN",       KeyEvent.VK_DOWN);
      keycodes.put("VK_LEFT",       KeyEvent.VK_LEFT);
      keycodes.put("VK_RIGHT",      KeyEvent.VK_RIGHT);
      keycodes.put("VK_PAGE_UP",    KeyEvent.VK_PAGE_UP);
      keycodes.put("VK_PAGE_DOWN",  KeyEvent.VK_PAGE_DOWN);
      keycodes.put("VK_INSERT",     KeyEvent.VK_INSERT);
      keycodes.put("VK_DELETE",     KeyEvent.VK_DELETE);
      keycodes.put("VK_BACK_SPACE", KeyEvent.VK_BACK_SPACE);
      keycodes.put("VK_HOME",       KeyEvent.VK_HOME);
      keycodes.put("VK_END",        KeyEvent.VK_END);
      keycodes.put("VK_NUM_LOCK",   KeyEvent.VK_NUM_LOCK);
      keycodes.put("VK_CAPS_LOCK",  KeyEvent.VK_CAPS_LOCK);
      keycodes.put("VK_PAUSE",      KeyEvent.VK_PAUSE);
      
      // this maps the non-visible portions of the lower ASCII table (0 - 31
      // and 127) to the CTRL+character equivalents 
      ctrlCodes.put('@', 0);
      ctrlCodes.put('A', 1);
      ctrlCodes.put('B', 2);
      ctrlCodes.put('C', 3);
      ctrlCodes.put('D', 4);
      ctrlCodes.put('E', 5);
      ctrlCodes.put('F', 6);
      ctrlCodes.put('G', 7);
      ctrlCodes.put('H', 8);
      ctrlCodes.put('I', 9);
      ctrlCodes.put('J', 10);
      ctrlCodes.put('K', 11);
      ctrlCodes.put('L', 12);
      ctrlCodes.put('M', 13);
      ctrlCodes.put('N', 14);
      ctrlCodes.put('O', 15);
      ctrlCodes.put('P', 16);
      ctrlCodes.put('Q', 17);
      ctrlCodes.put('R', 18);
      ctrlCodes.put('S', 19);
      ctrlCodes.put('T', 20);
      ctrlCodes.put('U', 21);
      ctrlCodes.put('V', 22);
      ctrlCodes.put('W', 23);
      ctrlCodes.put('X', 24);
      ctrlCodes.put('Y', 25);
      ctrlCodes.put('Z', 26);
      ctrlCodes.put('[', 27);
      ctrlCodes.put('\\', 28);
      ctrlCodes.put(']', 29);
      ctrlCodes.put('^', 30);
      ctrlCodes.put('_', 31);
      ctrlCodes.put('?', 127);
   }
   
   /**
    * Create an instance with the communication session set to the given
    * value.
    *
    * @param    session
    *           The data source/sink for this terminal.
    * @param    cfg
    *           Configuration values for the terminal.
    */
   public Terminal(FilterPlugin session, TerminalSessionSettings cfg)
   {
      super(cfg.termColumns, cfg.termRows);
      this.session = session;
      
      setTerminalID(cfg.termType);
      LogHelper.finer("SETTERMINALTYPE|" + cfg.termType);
      
      // insert a display processor that copies the current screen to a
      // secondary buffer during redrawing
      VDUDisplay disp = new VDUDisplay()
      {
         /** Merged, persistent screen buffer. */
         char[][] buffer = new char[height][width];
         
         public void redraw()
         {
            LogHelper.finest("merging new drawing into buffer");
            // merge the newly drawn updates into our buffer
            for (int n = 0; n < height; n++)
            {
               // check the update array to see if we can avoid this line
               // (since it has not changed)
               if (!update[0] && !update[n + 1])
                  continue;
               
               // this line must be newer, clear the update flag
               update[n + 1] = false;
               
               // examine the line
               for (int x = 0; x < width; x++)
               {
                  int idx  = n + screenBase;
                  int attr = charAttributes[idx][x];
                  
                  // check visibility of the character
                  if ((attr & VDUBuffer.INVISIBLE) == 0)
                  {
                     // overwrite the character
                     buffer[n][x] = charArray[idx][x];
                  }
               }
            }
            
            // clear the "overall" update flag (the first entry in the array
            // doesn't refer to any line but is a global flag, so all other
            // lines have a flag at line number + 1 in the array
            update[0] = false;
            
            LogHelper.finest("new screen, height=" + height + ", width=" + width);
            synchronized (lock)
            {
               // create a new, empty snapshot
               screen = new char[height][width];
      
               // copy our merged buffer into it
               for (int k = 0; k < height; k++)
               {
                  System.arraycopy(buffer[k], 0, screen[k], 0, width);
               }
               if (Harness.isBenchmarkMode())
               {
                  lock.notifyAll();
               }
            }
            if (LogHelper.isFinestLoggable())
            {
               for(int y = 0 ; y < height ; y++)
               {
                  StringBuffer line = new StringBuffer(); 
                  for(int x = 0 ; x < width ; x++)
                     line.append(buffer[y][x] > 0 ? (buffer[y][x]) : ' ');
                  LogHelper.finest(line.toString());
               }
            }
         }
         
         public void updateScrollBar() {}
         public void setVDUBuffer(VDUBuffer buffer) {}
         public VDUBuffer getVDUBuffer() { return Terminal.this; }
      };
      setDisplay(disp);
         
      // start a thread to service the incoming screen output
      Runnable reader = new Runnable()
      {
         public void run()
         {
            byte[] buffer = new byte[BUFFER_SIZE];
            
            try
            {
               LogHelper.finest("polling for screen data");
               int x=0;
               while (true)
               {
                  int i = Terminal.this.session.read(buffer);
                  if (LogHelper.isFinestLoggable())
                  {
                     LogHelper.finest("read results in i=" + i);
                  }
                  // watch for EOF
                  if (i < 0)
                     break;
                  
                  if (i > 0)
                  {
                     putString(new String(buffer, 0, i, ENCODING));
                     
                     // update the counter of bytes read (must be done after
                     // all processing is complete)
                     synchronized (lock)
                     {
                        drain += i;
                        if (Harness.isBenchmarkMode())
                        {
                           lock.notifyAll();
                        }
                     }
                  }
               }
            }
            
            catch (IOException ioe)
            {
               // ignore and exit
            }
         }
      };
      
      LogHelper.finest("starting readerThread");
      Thread readerThread = new Thread(reader);
      readerThread.setDaemon(true);
      readerThread.setName("Terminal Reader");
      readerThread.start();
      
      // wait up to 30 seconds for the 1st screen to come through
      LogHelper.finest("waiting for up to 30 seconds for the first screen...");
      waitForScreen(30000);
   }
   
   /**
    * Utility method to convert a screen buffer into a string. <code>null</code>
    * bytes are converted to spaces except at the end of a line (where they are
    * dropped).
    *
    * @param    buffer
    *           The screen data.
    *
    * @return   The string form of the data.
    */
   public static String screenToString(char[][] buffer)
   {
      if (buffer == null)
         return "";
      
      StringBuilder sb = new StringBuilder();
      
      // loop through the 2-dimensional array
      for (int i = 0; i < buffer.length; i++)
      {
         if (i > 0)
         {
            sb.append("\n");
         }
         
         // track the number of trailing null (0) bytes on this line
         int last = 0;
         
         for (int j = 0; j < buffer[i].length; j++)
         {
            // process the character
            if (buffer[i][j] != 0)
            {
               // anything other than a 0 is considered data
               last = 0;
               sb.append(buffer[i][j]);
            }
            else
            {
               // convert 0 to space
               last++;
               sb.append(' ');
            }
         }
         
         if (last > 0)
         {
            // extra null bytes at the end of the line must be removed
            sb.setLength(sb.length() - last);
         }
      }
      
      return sb.toString();
   }
   
   /**
    * Allow wait for events on lock
    *
    * @param timeout
    *        timeout if need to wait on lock
    * @param minimumChars
    *        minimum characters waiting to prevent the lock 
    */
   public void waitForDrain(long timeout, int minimumChars) throws InterruptedException
   {
      synchronized (lock)
      {
         if (drain >= minimumChars)
            return;
         lock.wait(timeout);
      }
   }

   public int resetDrain(long timeOut, int minimumChars) throws InterruptedException
   {
      int count = -1;
      synchronized (lock)
      {
         count = drain;
         drain = 0;
         if (count < minimumChars)
            lock.wait(timeOut);
         count += drain;
      }
      return count;
   }
   
/**
    * Set the drained bytes counter to zero and return the prior value.
    *
    * @return   The previous value of the drain counter (the value before
    *           reset).
    */
   public int resetDrain()
   {
      int count = -1;
      
      synchronized (lock)
      {
         count = drain;
         drain = 0;
      }
      
      return count;
   }
   
   /**
    * Convert any combination of <code>CTRL</code> and one of a specific list
    * of characters to the ASCII control codes. For example, CTRL + 'C' (or 'c')
    * convert to ASCII 3 ("end of text" or EOT).
    *
    * @param    ch
    *           The character to convert.
    * @param    modifiers
    *           The current modifiers in effect.
    *
    * @return   If the <code>CTRL</code> modifier is in effect AND the character
    *           is one that is mapped to a control code, then that control code
    *           will be returned. Otherwise, the given character will be
    *           returned unmodified.
    */
   public char calcCtrlCode(char ch, int modifiers)
   {
      if ((modifiers & VDUInput.KEY_CONTROL) != 0)
      {
         Integer code = ctrlCodes.get(Character.toUpperCase(ch));
         
         if (code != null)
         {
            ch = (char) code.intValue();
         }
      }
      
      return ch;
   }
   
   /**
    * Convert a string containing modifier key names (CTRL, ALT and/or SHIFT)
    * into the proper integer encoded form.
    *
    * @param    list
    *           Zero or more modifier key names. These can be separated by
    *           spaces, commas, plus signs or any other separator. Case will
    *           be ignored. This may be empty or <code>null</code>
    *
    * @return   The properly encoded modifiers or 0 if there are no valid
    *           modifiers present.
    */
   public int calcModifiers(String list)
   {
      int modifiers = 0;
      
      if (list != null && list.length() > 0)
      {
         list = list.toUpperCase();
         
         if (list.indexOf("CTRL") >= 0)
            modifiers |= VDUInput.KEY_CONTROL;
         
         if (list.indexOf("ALT") >= 0)
            modifiers |= VDUInput.KEY_ALT;
         
         if (list.indexOf("SHIFT") >= 0)
            modifiers |= VDUInput.KEY_SHIFT;
      }
      
      return modifiers;
   }
   
   /**
    * Convert a string containing a special key name into the proper integer
    * encoded form and send it to the back end.
    * <p>
    * The following key names are valid:
    * <p>
    * <pre>
    * VK_ESCAPE
    * VK_F1
    * VK_F2
    * VK_F3
    * VK_F4
    * VK_F5
    * VK_F6
    * VK_F7
    * VK_F8
    * VK_F9
    * VK_F10
    * VK_F11
    * VK_F12
    * VK_UP
    * VK_DOWN
    * VK_LEFT
    * VK_RIGHT
    * VK_PAGE_DOWN
    * VK_PAGE_UP
    * VK_INSERT
    * VK_DELETE
    * VK_BACK_SPACE
    * VK_HOME
    * VK_END
    * VK_NUM_LOCK
    * VK_CAPS_LOCK
    * VK_PAUSE
    * VK_TAB
    * VK_ENTER
    * </pre>
    *
    * @param    key
    *           A special key name. Case will be ignored. If empty or
    *           <code>null</code>, this is a no-operation.
    * @param    modifiers
    *           Zero or more modifier key names (CTRL, ALT and/or SHIFT).
    *           These can be separated by spaces, commas, plus signs or any
    *           other separator. Case will be ignored. This may be empty or
    *           <code>null</code> which is the equivalent of no modifiers.
    */
   public void sendSpecialKey(String key, String modifiers)
   {
      if (key == null || key.length() == 0)
         return;
      
      int mods = calcModifiers(modifiers);
      
      key = key.toUpperCase();
      
      // ENTER, TAB and ESCAPE go through a different path
      if (key.equals("VK_ENTER"))
      {
         keyTyped(KeyEvent.VK_ENTER, (char) KeyEvent.VK_ENTER, mods);
      }
      else if (key.equals("VK_TAB"))
      {
         keyTyped(KeyEvent.VK_TAB, (char) KeyEvent.VK_TAB, mods);
      }
      else if (key.equals("VK_ESCAPE"))
      {
         keyTyped(KeyEvent.VK_ESCAPE, (char) KeyEvent.VK_ESCAPE, mods);
      }
      else
      {
         // common path for "special" keys
         int keyCode = -1;
         
         if (keycodes.containsKey(key))
         {
            Integer val = keycodes.get(key);
            
            if (val != null)
            {
               keyCode = val.intValue();
            }
         }
         
         if (keyCode != -1)
         {
            keyPressed(keyCode, (char) 0, mods);
         }
      }
   }
   
   /**
    * Send the text (with each character as an individual key) to the back
    * end.
    * <p>
    * Each key will be delayed for 50ms, if the delay parameter is less than 50ms; else, the 
    * specified delay is used.
    *
    * @param    text
    *           Zero or more characters to send to the back end. If empty or
    *           <code>null</code>, this is a no-operation.
    * @param    modifiers
    *           Zero or more modifier key names (CTRL, ALT and/or SHIFT).
    *           These can be separated by spaces, commas, plus signs or any
    *           other separator. Case will be ignored. This may be empty or
    *           <code>null</code> which is the equivalent of no modifiers.
    * @param    delay
    *           This is the number of milliseconds to wait after each key is sent.  If less than 
    *           50ms, it will default to 50ms. 
    */
   public void sendText(String text, String modifiers, int delay)
   {
      if (text == null || text.length() == 0)
         return;
      if (LogHelper.isFinestLoggable())
      {
         LogHelper.finest("SENDTEXT|" + text +"|");
      }
      delay = Math.max(delay, 50);
      if (Harness.isBenchmarkMode())
      {
         // benchmark mode sets hard limit on delay - 10 ms
         delay = 10;
      }
      int mods = calcModifiers(modifiers);
      
      for (int i = 0; i < text.length(); i++)
      {
         char next = calcCtrlCode(text.charAt(i), mods);
         
         keyTyped((int) next, next, mods);
         
         if (delay > 0)
         {
            try
            {
               Thread.sleep(delay);
            }
            
            catch (InterruptedException ie)
            {
               // TODO: fail here
               return;
            }
         }
      }
   }
   
   /**
    * Write a block of data to the back end.
    *
    * @param    b
    *           The buffer to be sent.
    */
   public void write(byte[] b)
   {
      try
      {
         session.write(b);
      }
      
      catch (IOException ioe)
      {
         // what to do here?
      }
   }
   
   /**
    * In performance test mode wait for screen change to be available. This can be used to
    * detect a new screen even if screen was not cleared
    * 
    *
    * @param    timeout
    *           Maximum number of milliseconds to wait or -1 to wait
    *           indefinitely.
    *
    * @return   <code>true</code> if a screen is available to be read.
    *           <code>false</code> indicates that a timeout or an
    *           interruption occurred.
    */
   public boolean waitForScreenPM(int timeout)
   {
      int     millis     = 0;
      boolean indefinite = false;
      
      if (timeout == -1)
      {
         indefinite = true;
      }
      
      while (timeout > 0 || indefinite)
      {
         synchronized (lock)
         {
            // don't block if screen has changed
            if (screen != null && screen != lastValidScreen)
            {
               lastValidScreen = screen;
               return true;
            }
            // wait for screen to change
            long waitStartTime = System.currentTimeMillis();
            try
            {
               lock.wait(timeout);
            }
            catch (InterruptedException ie)
            {
               // drop out
               break;
            }
            long waitStopTime = System.currentTimeMillis();
            if (!indefinite)
               timeout -= (waitStopTime-waitStartTime);
            waitStartTime = waitStopTime;
         }
      }
      
      return false;
   }
   
/**
    * Wait for any screen to be available. If the currently buffered screen
    * data was cleared before this method call, then this can be used to
    * detect a new screen, otherwise it will just report if there was ever
    * a screen that was read.
    *
    * @param    timeout
    *           Maximum number of milliseconds to wait or -1 to wait
    *           indefinitely.
    *
    * @return   <code>true</code> if a screen is available to be read.
    *           <code>false</code> indicates that a timeout or an
    *           interruption occurred.
    */
   public boolean waitForScreen(int timeout)
   {
      if (Harness.isBenchmarkMode())
      {
         return waitForScreenPM(timeout);
      }
      
      int     millis     = 0;
      boolean indefinite = false;
      
      if (timeout == -1)
      {
         indefinite = true;
      }
      
      while (timeout > 0 || indefinite)
      {
         synchronized (lock)
         {
            if (screen != null)
               return true;
         }
         
         try
         {
            millis = timeout <= 0 ? 1000 : Math.min(timeout, 1000); 
            Thread.sleep(millis);
         }
         
         catch (InterruptedException ie)
         {
            // drop out
            break;
         }
         
         if (!indefinite)
            timeout -= millis;
      }
      
      return false;
   }
   
   /**
    * Clear the currently buffered screen data. This allows any screen
    * changes to be detected.
    */
   public void clearScreen()
   {
      synchronized (lock)
      {
         screen = null;
      }
   }
   
   /**
    * Directly access the buffer data to capture a snapshot of the current
    * screen, without clearing the buffer data or waiting on a screen to
    * be read.
    *
    * @return   The characters on the screen at this time, with 1 character
    *           for every combination of valid row and column. This may be
    *           <code>null</code> if there is no screen available to be
    *           read (see {@link #clearScreen}).
    */
   public char[][] getScreen()
   {
      return getScreen(false, 0);
   }
   
   /**
    * Directly access the buffer data to capture a snapshot of the current
    * screen without waiting for a screen to be available.
    *
    * @param    clear
    *           <code>true</code> to clear the buffered screen state before
    *           returning the previous contents. This allows any screen
    *           updates to be detected.
    *
    * @return   The characters on the screen at this time, with 1 character
    *           for every combination of valid row and column. This may be
    *           <code>null</code> if there is no screen available to be
    *           read (see {@link #clearScreen}).
    */
   public char[][] getScreen(boolean clear)
   {
      return getScreen(clear, 0);
   }
   
   /**
    * Directly access the buffer data to capture a snapshot of the current
    * screen.
    *
    * @param    clear
    *           <code>true</code> to clear the buffered screen state before
    *           returning the previous contents. This allows any screen
    *           updates to be detected.
    * @param    timeout
    *           Use 0 if no waiting is desired, a positive number to specify
    *           the maximum number of milliseconds to wait until a screen
    *           becomes available and -1 to specify to wait indefinitely for a
    *           screen to be available.
    *
    * @return   The characters on the screen at this time, with 1 character
    *           for every combination of valid row and column.
    */
   public char[][] getScreen(boolean clear, int timeout)
   {
      if (Harness.isBenchmarkMode())
      {
         return getScreenPM(clear,timeout);
      }
      char[][] current = null;
      
      if (timeout == 0 || waitForScreen(timeout))
      {
         if (LogHelper.isFinestLoggable())
         {
            LogHelper.finest("getScreen: timeout=" + timeout + ", clear=" + clear + ", (screen == null)=" + (screen == null));
         }
         synchronized (lock)
         {
            current = screen;
            
            if (clear)
               screen = null;
         }
      }
      
      return current;
   }
   
   /**
    * Directly access the buffer data to capture a snapshot of the current
    * screen. Performance mode test version
    *
    * @param    clear
    *           <code>true</code> to clear the buffered screen state before
    *           returning the previous contents. This allows any screen
    *           updates to be detected.
    * @param    timeout
    *           Use 0 if no waiting is desired, a positive number to specify
    *           the maximum number of milliseconds to wait until a screen
    *           becomes available and -1 to specify to wait indefinitely for a
    *           screen to be available.
    *
    * @return   The characters on the screen at this time, with 1 character
    *           for every combination of valid row and column.
    */
   public char[][] getScreenPM(boolean clear, int timeout)
   {
      char[][] current = null;
      if (timeout == 0 || waitForScreen(timeout))
      {
         synchronized (lock)
         {
            current = screen;
            // for immediate checks provide the last valid screen if there was nothing newer 
            if (timeout == 0 && screen == null)
            {
               current = lastValidScreen;
            }
            if (clear)
            {
               // clean both screen buffers
               screen = null;
               lastValidScreen = null;
            }
         }
      }
      
      return current;
   }
}
