package nxm.sys.lib;

import java.net.MalformedURLException;
import java.util.Vector;

import nxm.sys.inc.Debug;
import nxm.sys.inc.ListFile;
import nxm.sys.inc.MidasReference;

/** A class to define and handle a Text file. <br><br>
    TextFiles tend to be line oriented files containing ASCII text. <br><br>
    NOTE: If the includeString property is set to a non-null value then the class
    will follow include directives in the file (i.e treat the file as a Macro file).

    @author Jeff Schoen
*/
public class TextFile extends BaseFile implements ListFile {
  /** default read/write buffer size */
  private static final int BUFFER_SIZE = 4096;
  private byte[] buffer = new byte[BUFFER_SIZE];
  private int bufin=0,bufout=0,lineOffset=0;

  /** The include string used by the {@link #readProper()} (null=ProcessingDone). */
  private String   inclStr  = null;
  /** The file that is chained to this one, used by {@link #readProper()}. */
  private TextFile inclFile = null;

  /** The number of lines read in by readProper(). */
  private int readProperCount = 0;

  /** Whether or not to check for multi-line comments ("/*") */
  private boolean allowMultiLineComments = true;

  /** the previous String was an unfinished quoted String (the line ended with '&') - Since 3.3.1 */
  private boolean midQuote = false;

  /** Initialize new instance */
  public TextFile() {
  }

  /** Initialize new instance with filename.
   *  IMPORTANT: Please use a TextFile constructor with a MidasReference
   *  as the default for null currently does not have MAINTAINCASE, but
   *  that is anticipated to change.
   *  @param filename The name of the file to use.
   */
  public TextFile (String filename) {
    super();
    init (null,filename);
  }

  /** Initialize new instance with FileName.
   * @param fn The name of the file to use.
   */
  public TextFile (FileName fn) {
    super();
    init (null,fn);
  }

  /** Initialize new instance with reference and filename
      @param ref      The reference to use (Ideally a {@link Command} for proper cmdline argument picking).
      @param filename The name of the file to use.
      @deprecated Since NeXtMidas 3.5.0 use {@link #TextFile(MidasReference, Object)}
   */
  @Deprecated
  public TextFile (Object ref, Object filename) {
    init(Convert.ref2MidasReference(ref), filename);
  }

  /** Initialize new instance with reference and filename
   *  @param ref      The reference to use (Ideally a {@link Command} for proper cmdline argument picking).
   *  @param filename The name of the file to use (FileName, String, otherwise Object's toString() will be used).
   *  @since NeXtMidas 3.5.0
   */
  public TextFile (MidasReference ref, Object filename) {
    super();
    init(ref, filename);
  }

  /** Opens the text file (see {@link BaseFile#open(int)} for details).
      <pre>
        WARNING: Unlike {@link BaseFile} and {@link DataFile} this method implicitly
                 acts as if the {@link #NOABORT} flag has been given. As of NeXtMidas
                 2.5.0, this behavior is deprecated; in future releases this method
                 will behave the same as {@link BaseFile#open(int)} and error out
                 if a file is not found.

                 To avoid deprecation warnings, include one of the following flags:
                    {@link #NOABORT}    - Default in release 2.4.0
                    {@link #FORCEABORT} - Will be default in future releases (matches BaseFile)
      </pre>
   */
  @Override
  public synchronized boolean open(int flag) { // No method override in future releases
    return super.open(flag);
  }

  /** Opens the text file (see {@link BaseFile#open()} for details). See {@link #open(int)} for
      an important note regarding the {@link #NOABORT} flag.
   */
  @Override
  public synchronized boolean open() { // No method override in future releases
    if ((flags & (NOABORT|FORCEABORT)) == 0) {
      try {
        if (((flags & INOUT) == INOUT) && !exists()) {
          M.warning("TextFile: "+getName()+" does not exist (flags="+getFlagsString()+") opening file for OUTPUT only."
                  + " -- This behavior is deprecated and will be disabled in future releases.");
          flags = flags & ~INPUT;
        }
        return super.open();
      } catch (MidasException e) {
        if (e.getCause() instanceof BaseFile.CouldNotFindException) {
          M.warning("Could not find URL="+getURL()+" -- This will be an ERROR rather than a WARNING "
                  + "in future releases (see TextFile.open(int) for more details).");
          return false;
        } else {
          throw e;
        }
      }
    }
    return super.open();
  }

  @Override
  public synchronized void close () {
    if ((M.debug&Debug.IO)!=0) M.info("CloseTF Path="+getURL());
    if (!isOpen) return;
    if (isOutput && bufin>0) {
      int bytesWritten = io.write(buffer,0,bufin);
      if (bytesWritten > 0) bufin =- bytesWritten; // since 3.7.1 as part of NXM-2921
    }
    io.close();
    isOpen = false; // since 3.7.1 as part of NXM-2921, NXM-2930
  }

  @Override
  public double getSize() {
    size = (double)io.getLength();
    return size;
  }

  public String getMimeType () {
    String ext = getName().getExt();

    if (ext != null) { // check special cases
      ext = ext.toLowerCase();
      if (ext.equals("xhtml")) return "text/html";
      if (ext.equals("html")) return "text/html";
      if (ext.equals("htm" )) return "text/html";
      if (ext.equals("jnlp")) return "application/x-java-jnlp-file";
      if (ext.equals("xml" )) return "text/xml"; // "application/xml" is also legal
      if (ext.equals("css" )) return "text/css";
      if (ext.equals("js" ))  return "application/x-javascript";
    }
    return "text/plain";
  }

  /** Flush any (written) cached data to disk (IOResource)
      @since NeXtMidas 3.0.2
   */
  @Override
  public synchronized void flush () {
    if (isOutput && (bufin > 0)) {
      io.write(buffer, 0, bufin);
      bufin = 0;
    }
  }

  /** Write a line of data (no terminator)
      @param text String to be written
   */
  public synchronized void write (String text) {
    write(text, false);
  }

  /** Write a line of data (no terminator)
      @param text String to be written
      @param holdflush true to disable FLUSH in flags, ONLY used by writeln
                     so that it can flush buffer to disk all at once.
      @since NeXtMidas 3.1.1: moved most of original write into here, adding holdflush param
   */
  private synchronized void write (String text, boolean holdflush) {
    if (!isOpen) return;
    int bytes = BUFFER_SIZE - bufin;
    int lenText = text.length();
    int bytesToWrite = lenText;
    int bytesWritten = 0;
    while (bytesToWrite >= bytes) {
      for (int i=0; i<bytes; i++) {
        buffer[bufin++]=(byte)text.charAt(i+bytesWritten);
      }
      io.write (buffer,0,bufin);
      bufin = 0;
      bytesToWrite = bytesToWrite - bytes;
      bytesWritten = bytesWritten + bytes;
    }
    // Store remainder in buffer to be written on flush or close
    for (int i=bytesWritten; i<lenText; i++) {
      buffer[bufin++] = (byte)text.charAt(i);
    }

    // since 3.1.1: write buffer disk if flush is enabled OR when buffer is full
    boolean doflush = !holdflush && (bufin > 0) && (flags&FLUSH)!=0;
    if (doflush || (bufin == BUFFER_SIZE)) {
      io.write(buffer, 0, bufin);
      bufin = 0;
    }
  }

  /** Write a line of data (with terminator)
      @param text String to be written
   */
  public synchronized void writeln (String text) {
    if (!isOpen) return;
    write(text, true);
    buffer[bufin++] = (byte)LF;

    // write to disk when flush is enabled (since 3.1.1) OR when buffer is full
    boolean doflush = (bufin > 0) && (flags&FLUSH)!=0;
    if (doflush || (bufin == BUFFER_SIZE)) {
      io.write(buffer, 0, bufin);
      bufin = 0;
    }
  }

  /** Read a line of data
      @return line of data */
  public synchronized String read () {
    if (!isOpen) return null;

    // If the include file is not null then try reading from that.  If the
    // return value from that is not null then that's our return value.  If it
    // is null then set the inclFile to null and read from this object.
    if ( inclFile != null ) {
      String line = inclFile.read();
      if ( line != null ) return line;
      else { inclFile.close();  inclFile = null; }
    }

    int i; String string=null;
    if (bufout>=bufin) { bufin = io.read (buffer,0,BUFFER_SIZE); bufout=0; }
    for (i=bufout; i<bufin && buffer[i]!=(byte)LF; i++);
    while (i==bufin && bufin==BUFFER_SIZE) {
      if (string == null) string  = new String(buffer,bufout,bufin-bufout);
      else                string += new String(buffer,bufout,bufin-bufout);
      bufin = io.read (buffer,0,BUFFER_SIZE); bufout=0;
      for (i=0; i<bufin && buffer[i]!=(byte)LF; i++);
    }
    if (i==bufout && bufin<=0);
    else if (string==null) string = new String(buffer,bufout,i-bufout);
    else string = string + new String(buffer,bufout,i-bufout);
    bufout = i+1;

    // Having read the line of input, see if we need to follow an include.  If
    // we do, then create the new TextFile and call its read method.
    if (inclStr != null && string != null && !midQuote) {  // check for midQuote added in 3.3.1 for Bug 1562
      FileName path = isIncludeLine(string);
      if ( path != null ) {
        inclFile = new TextFile(path);
        inclFile.setIncludeString(inclStr);
        inclFile.open(TextFile.FORCEABORT);

        string = inclFile.readProper();
      }
    }
    // take note if we are in a multi-line String or result
    if (string !=null && !string.equals("") && string.endsWith("&")) midQuote = true;
    else midQuote = false;

    return string;
  }

  /** Set the string that indicates an include file. If this is set to null
      then following includes is turned off.
      @param str The include string
  */
  public void setIncludeString (String str) {
    inclStr = str;
  }

  /** Return a valid pathname if the given line is an include directive. If the line is not an
      include line the method will return null. Include lines are indicated by any line that has
      the include string as its word. Any amount of whitespace before the string is allowed.
      Following the include string is the path components of the file to include. If there
      are no spaces in the path component (i.e. only one path component) the path constructed will
      be relative to this TextFile. If the path component has spaces then it is interpreted as
      file, area, option.  <br>
      <br>
      if the path component has semicolon them it is interpreted as file option tree.
      See the FileName.relativeTo method for more details as that's the method thats actually used.
      @param line The line from the text file being checked.
      @return A valid path if the line is an include or null if there is no include.
      @since NeXtMidas 3.7.1: allow include of files from other option trees.
  */
  public final FileName isIncludeLine (String line) {
    if ( inclStr == null || line == null ) return null;
    try {
      String ucLine = line.toUpperCase();
      String ucIncl = inclStr.toUpperCase();
      if ( ucLine.trim().startsWith(ucIncl) ) {
        int pos = ucLine.indexOf(ucIncl);
        if ( pos > -1 ) line = line.substring(pos + inclStr.length());
        line = StringUtil.stripQuotes(line.trim());
        // NXM-2942 include should find files with option tree. syntax: include <file>;<option>
        String mdelimiter= ";";
        if (line.contains(mdelimiter)) {
          String[] temp = StringUtil.split(line, mdelimiter);
          String fname, optname, area;
          if (temp.length > 1) {
            fname = temp[0];
            optname = temp[1];
            area = "MCR";
            String patharea = OptionTree.getOptPath(optname, area);
            if (patharea != null) {
              patharea = patharea+fname;
              return getFileName().relativeTo(patharea.trim());
            }
            else M.warning("Could not find corrected path to option tree: "+optname+" area: "+area);
          }
        }
        return getFileName().relativeTo(line.trim());
      }
    } catch ( MalformedURLException excep ) {
      M.warning ("Poorly formed url for " + line);
    }
    return null;
  }

  /** Read a line of data skipping comments and following line continuators
      @return full line of data */
  public String readProper () {
    String next,full=null;
    int ib,iq;
    int iSlashStar=-1, iStarSlash=-1;
    int multiCount = 0;
    boolean isQuoted = false;  // added in 3.1.0 for Bug 2491
    while ( (next=read()) != null) {
      readProperCount++;
      next = next.trim();
      ib = next.indexOf('!');
      if (ib==0) continue;
      if (ib>0) {
        iq = (isQuoted) ? 1 : 0;
        for (int i=0; i<ib; i++) if (next.charAt(i)=='"') iq=1-iq;
        if (iq!=0) ib=-1;
      }
      if (ib>0) next = next.substring(0,ib);
      if (allowMultiLineComments) {
        // check for quotes around /* and */ added in 3.3.0 for Bug 2654
        iSlashStar = checkForQuotes(next, "/*", isQuoted);
        if (iSlashStar >= 0) multiCount++;
        iStarSlash = checkForQuotes(next, "*/", isQuoted);
        if (iStarSlash >= 0) multiCount--;

        if (iSlashStar == 0 && iStarSlash > 0) continue;  // single line comment
        if (multiCount > 0 || (multiCount==0 && iStarSlash==0) ) continue;  // multi line comment
        if (iSlashStar > 0 && next.endsWith("*/")) {
          next = next.substring(0,iSlashStar);   // trailing comment
        }

      }
      if (next.length()==0) continue;
      next = next.trim();
      if (full==null || full.length()==0) full = next;
      else full += next;
      if (!full.endsWith("&")) { isQuoted = false; break; }  // Bug 2491 change

      // Check the full text to see if it is part of a multi-line quote (Bug 2491)
      iq = (isQuoted) ? 1 : 0;
      for (int ii=0; ii<full.length(); ii++) {
        if (full.charAt(ii)=='"') iq=1-iq;
      }
      if (iq!=0) isQuoted=true;

      full = full.substring(0,full.length()-1);
      if (full.endsWith("&")) break;

    }
    return full;
  }

  /** Check a given String for a given match to see if the match is found, and if found,
      is it within quotes.
   */
  private int checkForQuotes (String text, String searchStr, boolean startsQuoted) {
    int iq    = startsQuoted? 1 : 0;
    int index = text.indexOf(searchStr);

    if (index<0) return -1;

    for (int i=0; i < index; i++) {  if (text.charAt(i)=='"') iq=1-iq;   }

    return (iq!=0)? -1 : index;
  }

  /** Gets the number of lines read in by readProper() */
  int getReadProperCount() {
    return readProperCount;
  }

// JC4: This does not really "read all" -- just try it on a URL to see this fail!
//  /** Read entire file into one string */
//  public String readAll () {
//    int size = (int)io.getLength();
//    byte[] buf = new byte[size];
//    io.read(buf,0,size);
//    return new String(buf);
//  }
  /** Read entire file into one string
      @return string containing entire file*/
  public String readAll () {
    return StringUtil.join(readAllLines(), "\n");
  }

  /** Read all the lines of a file in to an array.
      @return array containing lines of file */
  public String[] readAllLines () {
    if (lines == null) { readFile(); }
    return lines.toArray(StringUtil.EMPTY_STRING_ARRAY);
  }

  /** Read all lines of a file into an array.
   *  @param ref      The reference to use (Ideally a {@link Command} for proper cmdline argument picking).
   *  @param filename The name of the file to use (FileName, String, otherwise Object's toString() will be used).
   *  @return string array.
   *  @since NeXtMidas 3.7.0
   */
  public static String[] readAllLines (MidasReference ref, Object filename) {
    TextFile inFile = new TextFile(ref, filename);
    inFile.open(TextFile.FORCEABORT);
    String[] allLines = inFile.readAllLines();
    inFile.close();
    return allLines;
  }

  /** Give the text file name */
  @Override
  public String toString () { return "TextFile : " + getURL(); }

  /** List the text URL and size */
  @Override
  public String listHeader () {
    String list =
      "TextFile : "+getURL()+"\n"
    + "Size     : "+getSize()+" bytes\n";
    return list;
  }

  /** Copy the contents of this object to the other object one line at a time. Because this is done
      a line at a time includes will be obeyed if this instance is configured with them.
      @param out output file
      @return The number of lines that have been copied from this file to the output file.
      #deprecated Since NeXtMidas 2.5.0: Use {@link FileUtil#copyListFile(Midas,Object,Object)}
  */
  public int copy (TextFile out) { // this is Gordon code...
    if ( out == null ) {
      throw new IllegalArgumentException("out file cannot be null");
    }

    // Open myself for input if I'm not already.  If I'm doing the open, do
    // the close as well.

    boolean closeIn = false;
    if ( !isOpen() ) {
      if ( open(INPUT|NOABORT) != true ) {
        M.warning ("Unable to open URL=" + getURL() + " for input");
      }
      closeIn = true;
    }

    // Open out for output if it's not already.  If I'm doing the open, do the
    // close as well.
    boolean closeOut = false;
    if ( !out.isOpen() ) {
      if ( out.open (OUTPUT|NOABORT) != true ) {
        M.warning ("Unable to open URL=" + out.getURL() + " for output");
      }
      closeOut = true;
    }

    // Now just loop through my lines until I'm done.
    int nlines = 0;
    String line = null;
    while ( (line = read()) != null ) {
      out.write ( line + "\n");
      nlines++;
    }

    if ( closeIn )
      close();
    if ( closeOut )
      out.close();

    return nlines;
  }

  /** List a text file from a line offset for n-lines. File is read one line at a time.
      @param terminal    Terminal to display output
      @param lineOffset  Offset line into file, 0-n
      @param linesToShow Number of lines to show, 1-n
      @param showNumbers Show line numbers?
      @return number of lines read
  */
  public int list (Terminal terminal, long lineOffset, long linesToShow, boolean showNumbers) {
    long lastLineNum = linesToShow + lineOffset;

    boolean done = false; int linesRead = 0;
    String line = null;
    while (!done && linesRead < lastLineNum) {
      line = read(); linesRead++;
      if (line == null) done = true;
      else {
        if (linesRead > lineOffset) {
          if (showNumbers) M.println( ""+linesRead+": "+line );  // since 3.7.1 use M.println instead of terminal.writeln to allow logging of itneractive commands NXM-2813
          else M.println( line );
        }
      }
    }
    return linesRead;
  }

  @Override
  public String listElements (double start, int elem, String format, int flags) {
    String line="";
    for (;lineOffset<=start; lineOffset++) {
      line=read();
      if (line==null) return null;
    }
    return line+"\n";
  }

  @Override
  public int listElementsPerLine (int lineWidth, String format, int flags) {
    return 1;
  }

  private Vector<String> lines = null;

  private void readFile() {
    lines = new Vector<>();

    String line = null;

    while((line = read()) != null) {
      lines.add(line);
    }
  }

  @Override
  public double getNumberOfRows() {
    if (lines == null) { readFile(); }
    return lines.size();
  }

  @Override
  public boolean getProtected() {
    return true;
  }

  @Override
  public Table getDataTable(double offset) {
    if (offset < 0) return null;
    if (lines == null) { readFile(); }
    Table tbl = null;

    if ((offset < lines.size()) && (offset >= 0.0)) {
      tbl = new Table();
      tbl.setKey("TEXT", lines.get((int)offset));
    }

    return tbl;
  }

  @Override
  public int setData (double offset, Table table) {
    setData(offset, "TEXT", table.getS("TEXT", ""));
    return 1;
  }

  @Override
  public void setData (double element, String fieldName, Object obj) {
    if (lines == null) { readFile(); }
    if (fieldName.equals("TEXT")) {
      lines.set((int)offset, obj.toString());
    }
  }

  @Override
  public void insertData (double offset, Object obj) {
    if (lines == null) { readFile(); }

    if (obj instanceof Table) {
      obj = ((Table)obj).getKey("TEXT");
    }

    if ((offset >= 0) && (offset < lines.size())) {
      lines.insertElementAt(obj.toString(), (int)offset);
    } else if (offset >= 0) {
      while(offset > (lines.size()-1)) {
        lines.add("");
      }
      lines.add(obj.toString());
    }
  }

  @Override
  public final void removeData (double offset) {
    removeData(offset, 1);
  }

  @Override
  public void removeData (double offset, long count) {
    if (lines == null) { readFile(); }
    Util.removeListElements(lines, (int)offset, (int)count);
  }

  @Override
  public int getRecordDefCount() {
    return 1;
  }

  @Override
  public void setRecordDefs(Table defs) {
    if ((defs.size() == 1) && defs.getKeys()[0].equals("TEXT")) {
      // All is good... nothing to do.
    } else {
      throw new MidasException("TextFile: Can not alter record definitions in a TextFile.");
    }
  }

  @Override
  public Table getRecordDefs() {
    Table tbl = new Table();
    tbl.setKey("TEXT", getRecordDef(0));
    return tbl;
  }
  @Override
  public Table getRecordDef(int i) {
    Table tbl = new Table();
    tbl.setKey("NAME", "TEXT");
    return tbl;
  }

}
