/* **************************************************************************
 %name: %
 %version: %
 %date_modified: %

 Copyright (c) 1997,1998 Novell, Inc. All Rights Reserved.

 THIS WORK IS SUBJECT TO U.S. AND INTERNATIONAL COPYRIGHT LAWS AND TREATIES.
 USE AND REDISTRIBUTION OF THIS WORK IS SUBJECT TO THE LICENSE AGREEMENT
 ACCOMPANYING THE SOFTWARE DEVELOPMENT KIT (SDK) THAT CONTAINS THIS WORK.
 PURSUANT TO THE SDK LICENSE AGREEMENT, NOVELL HEREBY GRANTS TO DEVELOPER A
 ROYALTY-FREE, NON-EXCLUSIVE LICENSE TO INCLUDE NOVELL'S SAMPLE CODE IN ITS
 PRODUCT. NOVELL GRANTS DEVELOPER WORLDWIDE DISTRIBUTION RIGHTS TO MARKET,
 DISTRIBUTE, OR SELL NOVELL'S SAMPLE CODE AS A COMPONENT OF DEVELOPER'S
 PRODUCTS. NOVELL SHALL HAVE NO OBLIGATIONS TO DEVELOPER OR DEVELOPER'S
 CUSTOMERS WITH RESPECT TO THIS CODE.
****************************************************************************/

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

import javax.naming.*;

/**
 * Accepts commands from an input stream and executes these commands.
 *
 * <p>A current JNDI context is associated with the shell to facilitate
 * relative context operations. General-purpose methods are provided for
 * resolving absolute names, relative composite names, and relative
 * atomic names. Methods are provided that allows access to the input
 * and output streams associated with the shell. Also, there is a
 * mechanism for registering and deregistering commands.
 */
public class JNDIShell implements Runnable
{
   static final String quoteChar = "\"";
   static final String commentChars = "#;";

   /**
    * The stream from which commands are read.
    */
   InputStream i;

   /**
    * The stream to which results are written.
    */
   OutputStream o;

   /**
    * Allows the specification of a setup script to acheive a certain
    * state before giving control to the keyboard. If continueWStdIn
    * is set to TRUE and this shell is not a child shell, upon reading
    * the last command the input will be redirected from whatever
    * source it is retrieving commands, and will retrieve commands from
    * the system input stream. 
    */
   boolean continueWStdIn;

   /**
    * If this flag is true, and an exception is thrown inside a command,
    * a stack trace is printed for that exception.
    */
   boolean verbose;

   Hashtable cmds = new Hashtable ();
   NamedContext currCtx;
   String prompt = "";
   boolean shouldExit = false;
   int nestedLevel = 0;

   /**
    * Constructs a child shell, copying all parameters from the parent
    * shell as needed.
    *
    * @param shell   The parent shell.
    * @param initCtx An optional initial context. If NULL,
    *                then the current context is copied over
    *                from the parent shell.
    * @param i       The input stream for the child. If NULL,
    *                the input stream of the parent is used.
    * @param o       The output stream for the child. If NULL,
    *                the output stream of the parent is used.
    */
   public JNDIShell (JNDIShell shell, Context initCtx, InputStream i,
         OutputStream o)
   {
      this.i = (i == null ? shell.i : i);
      this.o = (o == null ? shell.o : o);
      this.continueWStdIn = shell.continueWStdIn;
      this.verbose = shell.verbose;

      if (initCtx != null)
      {
         this.currCtx = new NamedContext (new CompositeName (), initCtx,
               initCtx);
         this.prompt = "";
      }
      else
      {
         this.currCtx = shell.currCtx;
         this.prompt = shell.prompt;
      }

      this.cmds = (Hashtable) shell.cmds.clone ();
      this.nestedLevel = shell.nestedLevel + 1;
   }

   /**
    * Constructs a new shell with an initial context and an input
    * stream. All output is written to a sink, and not displayed.
    *
    * @param initCtx The context to use as root of the shell.
    * @param i       The input stream from which to read commands.
    */
   public JNDIShell (Context initCtx, InputStream i)
   {
      this (initCtx, i, null);
   }

   /**
    * Constructs a new shell with an an initial context, an input
    * stream and an output stream.
    *
    * @param initCtx The context to use as the root of the shell.
    * @param i       The input stream from which to read commands.
    * @param o       The output stream to which to write results 
    *                (can be NULL).
    */
   public JNDIShell (Context initCtx, InputStream i, OutputStream o)
   {
      this (initCtx, i, o, false, false);
   }

   /**
    * Constructs a new shell with an initial context, an input stream,
    * an output stream, and two flag options (continueWStdIn and verbose).
    *
    * @param initCtx The context to use as root of the shell.
    * @param i       The input stream from which to read commands.
    * @param o       The output stream to which results are written
    *                (can be NULL).
    * @param continueWStdIn Causes the input to be redirected upon
    *                       reading the last command from whatever source
    *                       it is retrieving commands, and will retrieve
    *                       commands from System.in when this flag is set
    *                       to TRUE and this shell is not a child shell.
    * @param verbose Causes a stack trace to be printed when and an
    *                exception is thrown inside a command and this flag
    *                is set to TRUE.
    */
   public JNDIShell (Context initCtx, InputStream i, OutputStream o,
         boolean continueWStdIn, boolean verbose)
   {
      this.currCtx = new NamedContext (new CompositeName (), initCtx,
            initCtx);

      this.i = i;
      this.o = (o == null ? new NullOutputStream () : o);

      this.continueWStdIn = continueWStdIn;
      this.verbose = verbose;
   }

   /**
    * Prints the exception, handling nested exceptions up to 5 levels.
    *
    * @param cmdName The command name that generated the original
    *                exception.
    * @param output  The output stream to which the exception is written.
    * @param e       The exception to print.
    * @param verbose A boolean that determines whether to print verbose
    *                exception information.
    */
   protected static void printException (String cmdName, PrintWriter output,
         Throwable e, boolean verbose)
   {
      // one exception has already happened, pass in level as 1.

      printException (cmdName, output, e, verbose, 1);
   }

   /**
    * Prints the exception, handling nested exceptions up to 5 levels.
    *
    * When the exceptions occur and it is being printed out, this
    * method is called recursively with a 5 level deep limit. A try/catch
    * is put around the exception printing code, because an exception can
    * be generated while printing the toString() of the original exception.
    *
    * @param  cmdName The command name that generated the original
    *                 exception.
    * @param  output  The output stream to which the exception is written.
    * @param  e       The exception to print.
    * @param  verbose A boolean that determines whether to print verbose
    *                 exception information. If verbose is set to TRUE,
    *                 then a stack trace is printed.
    * @param  level   The current recursion level.
    */
   private static void printException (String cmdName, PrintWriter output,
         Throwable e, boolean verbose, int level)
   {
      try
      {
         if (level < 2)
            output.print (cmdName + ": ");

         output.print ("exception: ");
         
         if (verbose)
         {
            e.printStackTrace (output);

            if (e instanceof NamingException)
            {
               Throwable root;
               
               root = ((NamingException) e).getRootCause ();
               
               if (root != null)
               {
                  output.print ("Root ");
                  
                  printException(cmdName, output, root, verbose, level + 1);
               }
            }
         }
         else
            output.println (e);
      }
      catch (Exception realBad)
      {
         if (level >= 5)
         {
            output.println ("error: over 5 nested exceptions");
            output.println ("last exception thrown: " +
                  realBad.getClass ().getName ());
         }
         else
            printException (cmdName, output, realBad, verbose, level + 1);
      }
   }

   /**
    * This run method is the main control loop of the shell. It
    * will exit only when a ShellException is thrown from a command
    * with the exitFlag set to TRUE. It first gets a print stream
    * so messages can be printed.
    */
   public void run ()
   {
      PrintWriter output = new PrintWriter (o);
      LineReader lineReader = new LineReader (new InputStreamReader(i));
      boolean done = false;

loop: while (!done)
      {
         // print prompt
         output.print (prompt + ">");
         output.flush ();

         String cmdLine = null;

         // read in the command line
         try
         {
            cmdLine = lineReader.readLine ();
         }
         catch (IOException e)
         {
            output.println ("fatal: IOException when reading input...");
            break loop;
         }

         // if not EOF yet
         if (cmdLine != null)
         {
            boolean doneParsing = false;
            Vector tokens = new Vector ();
            int tokenCount = 0;
            String cmdName = null;
            String token;
            int quoteParsePos = 0;

            // check for comment characters, but not inside quotes
            if (cmdLine.length () > 0)
            {
               boolean insideQuote = false;
               char[] chars = cmdLine.toCharArray ();

               for (int i = 0; i < chars.length; i++)
               {
                  if (JNDIShell.quoteChar.charAt (0) == chars[i])
                     insideQuote = !insideQuote;

                  if (!insideQuote &&
                      JNDIShell.commentChars.indexOf (chars[i]) != -1)
                  {
                     cmdLine = cmdLine.substring (0, i);
                     break;
                  }
               }
            }

            StringTokenizer t = new StringTokenizer (cmdLine);

            while (!(!t.hasMoreTokens () || doneParsing))
            {
               token = t.nextToken ();

               /* a token that starts with the quote signifies the beginning
                * of a quoted item.
                */
               if (token.startsWith (JNDIShell.quoteChar))
               {
                  /* Go back to the raw command line, and pull the literal
                   * between the quotes.
                   */
                  quoteParsePos = cmdLine.indexOf (token, quoteParsePos) + 1;
                  int endQuotePos = cmdLine.indexOf (JNDIShell.quoteChar,
                        quoteParsePos);

                  if (endQuotePos < 0)
                  {
                     output.println ("error: invalid quotes in command");
                     continue loop;
                  }

                  /* Pull off any more tokens until we get a token with
                   * a quote in it, which signifies end of quotation.
                   * Side effect: "Trees and more words"xxx_not_seen
                   * will look like this token: 'Trees and more words'.
                   */
                  if (token.lastIndexOf (JNDIShell.quoteChar) < 1)
                  {
                     while (t.hasMoreTokens ())
                     {
                        if (t.nextToken ().
                            indexOf (JNDIShell.quoteChar) != -1)
                        {
                           break;
                        }
                     }
                  }

                  token = cmdLine.substring (quoteParsePos, endQuotePos);
               }

               tokenCount++;

               /* the first token is the command name.
                */
               if (tokenCount == 1)
                  cmdName = token;
               /* All other tokens are added to a Vector.
                */
               else
                  tokens.addElement (token);
            }

            /* If a command was entered then handle it, otherwise just
             * go to the prompt again.
             */
            if (tokenCount > 0)
            {
               /* Search for entered command.
                */
               Command command = (Command) cmds.get (cmdName);
               String[] cmdArgs;

               if (command == null)
               {
                  output.println ("error: unrecognized command: " + cmdName);
               }
               else
               {
                  try
                  {
                     // pack all args in a String[]
                     cmdArgs = new String[tokenCount - 1];

                     for (int i = 0; i < tokenCount - 1; i++)
                        cmdArgs[i] = (String) tokens.elementAt (i);

                     command.execute (this, cmdArgs);

                     /* If the verbose option is TRUE, print an
                      * affirmative message saying that the command was
                      * successful.
                      */
                     if (verbose)
                        output.println (cmdName + ": sucessful");
                  }
                  catch (ShellException e)
                  {
                     /* Catch the special shell exception and check to 
                      * see if it means that we should exit the shell.
                      */  
                     String message = e.getMessage ();

                     if (message != null)
                        output.println (cmdName + ": " + e.getMessage ());

                     done = e.shouldExit ();
                  }
                  catch (Throwable e)
                  {
                     /* Display the exception. The verbose flag is taken
                      * into account if an exception is thrown when printing
                      * an exception, then this method recurses to print the
                      * offending exception.
                      */
                     JNDIShell.printException (cmdName, output, e, verbose);
                  }
               }
            }
         }
         // EOF signifies end of the input stream
         else
         {
            /* If the continueWStdIn flag is TRUE and this shell is not
             * a child shell, then redirect the input from the input stream
             * to System.in.
             */
            if (continueWStdIn && nestedLevel == 0)
            {
               lineReader = new LineReader(
                     new InputStreamReader(i = System.in));

               continueWStdIn = false;
            }
            else
               done = true;
         }
      }

      if (nestedLevel == 0)
         output.println ("exiting JNDIShell...");

      output.flush ();
   }

   /**
    * Binds a command to a string command name in the shell.
    *
    * @param cmdName The command name.
    * @param cmd     The command.
    */
   public void addCommand (String cmdName, Command cmd)
   {
      cmds.put (cmdName, cmd);
   }

   /**
    * Unbinds and returns the command from a string command name in
    * the shell.
    *
    * @param cmdName The command name.
    *
    * @return The command bound to the specified command name.
    */
   public Command removeCommand (String cmdName)
   {
      return ((Command) cmds.remove (cmdName));
   }

   /**
    * Returns the command bound to the specified command name.
    *
    * @param cmdName The command name.
    *
    * @return The command bound to the specified command name.
    */
   public Command getCommand (String cmdName)
   {
      return ((Command) cmds.get (cmdName));
   }

   /**
    * Returns a String array of all command names bound to the shell.
    *
    * @return A String array of all command names bound to the shell.
    */
   public String[] getCommandNames ()
   {
      String[] arr = new String[cmds.size ()];
      Enumeration enum = cmds.keys ();
      int i = 0;

      while (enum.hasMoreElements ())
         arr[i++] = (String) enum.nextElement ();

      return (arr);
   }

   /**
    * Clears all bound commands in the shell.
    */
   public void clearCommands ()
   {
      cmds.clear ();
   }

   /**
    * Resolves a name relative to the initial context specified in
    * the contstructor.
    *
    * @param sName The name that is to be resolved.
    *
    * @return The resolved name as a NamedObject.
    */
   public NamedObject resolve (String sName)
      throws NamingException
   {
      NamedContext initCtx = new NamedContext (new CompositeName (),
            currCtx.getInitialContext (), currCtx.getInitialContext ());

      return (resolveRelative (initCtx, sName));
   }

   /**
    * Packs a name by removing all redundant components.
    *
    * An '...' is expanded to '../..' and 'xxx/..' is removed because it is
    * redundant. If the packed relative name still has any '..' components,
    * the number of those components is returned. This allows the caller
    * to determine if it is a forward relative name or a backward
    * relative name.
    *
    * @param name The relative name to be packed.
    *
    * @return The number of packed relative names.
    */
   private int packName (Name name)
      throws NamingException
   {
      int relativeDepth = 0;

loop: for (int i = 0; i < name.size (); )
      {
         String component = name.get (i);

         if (component.equals (".."))
         {
            // delete redundant '..'s
            if (i > 0 && !name.get (i - 1).equals (".."))
            {
               name.remove (--i);
               name.remove (i);
            }
            else
            {
               relativeDepth--;
               i++;
            }
         }
         // expand multiple dotted items to '../..' ...
         else if (component.startsWith (".."))
         {
            char ch;

            for (int j = 0; j < component.length (); j++)
            {
               /* If the component starts with .. but contains any other
                * character, then it is not expanded.
                */
               if (component.charAt (j) != '.')
               {
                  i++;
                  continue loop;
               }
            }

            int equivalentDepth = component.length () - 1;

            name.remove (i);
            for ( ; equivalentDepth > 0; equivalentDepth--)
               name.add (i, "..");
         }
         // remove all '.' components
         else if (component.equals ("."))
            name.remove (i);
         else
            i++;
      }

      return (relativeDepth);
   }

   /**
    * Resolves a name relative to a provided context.
    *
    * @param ctx The NamedContext of the name to be resolved.
    *
    * @return The name to be resolved.
    */
   public NamedObject resolveRelative (NamedContext ctx, String sName)
      throws NamingException
   {
      Name name = new CompositeName (sName);
      Name displayName = new CompositeName ();
      int relativeDepth;
      Context contextToLookupFrom = ctx.getContext ();

      /* Determine if we can simply resolve the name, or if we must
       * go back to the initial context to do the resolution.
       */
      relativeDepth = packName (name);

      Name ctxName = ctx.getName ();

      /* Must go back to the initial context for resolution.
       */
      if (relativeDepth < 0)
      {
         /* If there aren't enough components in the current context
          * to allow us to go back as far as the command wants us
          * to, then throw an error.
          */
         if (ctx.getName ().size () + relativeDepth < 0)
         {
            throw new InvalidNameException ("invalid relative name");
         }
         else
         {
            // back up to the appropriate name
            for (int i = relativeDepth; i < 0; i++)
               name.remove (0);

            /* Construct a full name that references the object
             * relative to the initial context.
             */
            name.addAll (0, ctxName.getPrefix (
                  ctxName.size () + relativeDepth));

            contextToLookupFrom = currCtx.getInitialContext ();
         }

         /* Use the full name if an absolute resolution is being done.
          */ 
         displayName.addAll (name);
      }
      else
      {
         /* Use the current context's name if doing forward resolution, 
          */
         displayName.addAll (ctx.getName ());
         displayName.addAll (name);
      }

      /* Call JNDI context to resolve the name.
       */
      return (new NamedObject (displayName,
            contextToLookupFrom.lookup (name)));
   }

   /**
    * Resolves an atomic name relative to a provided context.
    *
    * Atomic name resolution is a bit different than composite name
    * resolution. It is only possible to resolve an atomic name in a
    * forward direction (that is, no '..' components are allowed) because
    * the separator character is configurable in an atomic naming system
    * and could conflict with '..' causing mass confusion.
    *
    * When resolving relative to an atomic context, the last component
    * of the context name is assumed to be an atomic name. The name
    * parser of the context allows us to discover the separator character
    * for the atomic naming system. We can then append the separator
    * character and the relative name, and then call JNDI to resolve the
    * name.
    *
    * @param ctx   The context of the atomic name.
    * @param sName The atomic name to be resolved.
    *
    * @return The resolved atomic name as a NamedObject.
    *
    * @exception NamingException 
    */
   public NamedObject resolveRelativeAtomic (NamedContext ctx, String sName)
      throws NamingException
   {
      /* Get the name parser for the current context from the nameParser.
       * You can get the name syntax, which we must have to determine what
       * the atomic separator is.
       */
      NameParser nameParser = ctx.getContext ().getNameParser ("");

/*
 * As of June 1997, JNDI was updated. The NameParser interface does
 * not have a getNamingConvention() method, and therefore, it is
 * impossible to do this check. (26 June 1997 jgs)
 *
 *      // if this is a flat name space then treat the atomic name just like
 *      //   a relative composite name.
 *      if ("flat".equals (nameParser.getNamingConvention ().
 *          getProperty ("jndi.syntax.direction")))
 *      {
 *         return (resolveRelative (ctx, sName));
 *      }
 */
      // parse out the relative atomic name
      Name relAtomicName = nameParser.parse (sName);
      Name fullCtxName = ctx.getName ();
      Name penultimateName;
      String baseAtomicNameStr;

      /* Set up the penultimate name (name up to the atomic component)
       * and get the atomic component.
       */
      if (fullCtxName.size () > 1)
      {
         /* Strip off the last component (the atomic component that is
          * the base for the resolution). The rest of the composite name
          * will remain the same, so we only want to modify the atomic
          * portion of the name.
          */
         penultimateName = fullCtxName.getPrefix (fullCtxName.size () - 1);

         baseAtomicNameStr = fullCtxName.get (fullCtxName.size () - 1);
      }
      else
      {
         penultimateName = new CompositeName ();

         if (fullCtxName.size () == 1)
            baseAtomicNameStr = fullCtxName.get (0);
         else
            baseAtomicNameStr = "";
      }

      /* Parse out the base atomic name, so we can do name operations
       * with it.
       */
      Name baseAtomicName = nameParser.parse (baseAtomicNameStr);
      Name displayName = new CompositeName ();

      /* Append the relative name to the base name. If the syntax specifies
       * right-to-left direction, append() puts the relative name on the
       * front of the name, not the back.
       */
      baseAtomicName.addAll (relAtomicName);
//System.out.println ("->resultant base atomic name: '" + baseAtomicName.toString () + "'");

      /* Construct the full name (including atomic component).
       */
      displayName.addAll (penultimateName);
      displayName.add (baseAtomicName.toString ());
//System.out.println ("->resultant full name: '" + displayName.toString () + "'");
//System.out.println ("->resultant relative name: '" + relAtomicName.toString () + "'");

      /* Call JNDI context to resolve the name.
       */
      return (new NamedObject (displayName,
            ctx.getContext ().lookup (relAtomicName)));
   }

   /**
    * Returns the current context.
    *
    * @return The current context.
    */
   public NamedContext getCurrCtx ()
   {
      return (currCtx);
   }

   /**
    * Sets the current context.
    *
    * @param ctx The current context.
    */
   public void setCurrCtx (NamedContext ctx)
   {
      currCtx = ctx;
   }

   /**
    * Sets the prompt.
    *
    * @param prompt The prompt to be set.
    *
    * @return The prompt that is set.
    */
   public String setPrompt (String prompt)
   {
      String save = this.prompt;

      this.prompt = prompt;

      return (save);
   }

   /**
    * Returns the verbose state.
    *
    * @return The verbose state, which is a boolean set to TRUE or FALSE.
    */
   public boolean getVerbose ()
   {
      return (verbose);
   }

   /**
    * Sets the verbose state.
    *
    * @param verbose The verbose state as a boolean set to TRUE or FALSE.
    */
   public void setVerbose (boolean verbose)
   {
      this.verbose = verbose;
   }

   /**
    * Returns the input stream attached to the shell.
    *
    * @return The input state attached to the shell.
    */
   public InputStream getInputStream ()
   {
      return (i);
   }

   /**
    * Returns the output stream attached to shell.
    *
    * @return The output stream attached to the shell.
    */
   public OutputStream getOutputStream ()
   {
      return (o);
   }
}
