package nxm.ucl.prim;

import java.util.ArrayList;

import nxm.sys.lib.MidasException;
import nxm.sys.lib.StringUtil;
import nxm.sys.lib.Terminal;
import nxm.sys.lib.TextFile;
import nxm.ucl.inc.DiffTool;
import nxm.ucl.inc.DiffTool.FileDiff;
import nxm.ucl.lib.JavaParser;
import nxm.ucl.lib.Lexer;

/** This primitive can be used to compare the differences between two text
    files.

    @author  J. Czechowski IV
    @since   NeXtMidas 1.9.2
 */
public class diff extends nxm.sys.lib.Primitive {
  /** The old file to be compared. */
  private TextFile oldFile;

  /** The new file to be compared. */
  private TextFile newFile;

  /** The text from the old file. This is a List of strings. */
  private ArrayList<String> oldText;

  /** The text from the new file. This is a List of strings. */
  private ArrayList<String> newText;

  /** The diff tool to use. */
  private DiffTool diffTool;

  /** The file type. */
  private String fileType;

  /** Indicates that changes in the amount of whitespace should be ignored. */
  private boolean ignoreWtSpace = false;

  /** Indicates that blank lines should be ignored. */
  private boolean ignoreBlankLines = false;

  /** Indicates that changes in case should be ignored. */
  private boolean ignoreCase = false;

  /** Indicates that changes in comments should be ignored (Java/MM Only). */
  private boolean ignoreComments = false;

  /** default constructor */
  public diff () {}

  public int open () {
    this.oldFile = MA.getTextFile("OLD");
    this.newFile = MA.getTextFile("NEW");
    this.fileType = MA.getS("TYPE").toUpperCase();

    if ((this.fileType == null) || (this.fileType.length() == 0)) {
      /* Determine based on extension of old file. */
      this.fileType = MA.getFileName("OLD").getExt().trim().toUpperCase();
    }

    /* Apply the standard ignores. */
    if (this.fileType.equals("JAVA")) {
      this.ignoreWtSpace    = true;
      this.ignoreBlankLines = true;
      this.ignoreCase       = false;
      this.ignoreComments   = true;
    } else if (this.fileType.equals("MM")) {
      this.ignoreWtSpace    = true;
      this.ignoreBlankLines = true;
      this.ignoreCase       = true;
      this.ignoreComments   = true;
    } else { /* "TXT" by default */
      this.ignoreWtSpace    = true;
      this.ignoreBlankLines = true;
      this.ignoreCase       = false;
      this.ignoreComments   = false;

      if (!this.fileType.equals("TXT")) {
        M.warning("Unknown file type '"+this.fileType+"' treating as 'TXT'.");
        this.fileType = "TXT";
      }
    }

    /* Apply switches. */
    this.ignoreWtSpace    = MA.getState("/WS",  this.ignoreWtSpace);
    this.ignoreBlankLines = MA.getState("/BL",  this.ignoreBlankLines);
    this.ignoreCase       = MA.getState("/CS",  this.ignoreCase);
    this.ignoreComments   = MA.getState("/CMT", this.ignoreComments);


    this.oldFile.open(TextFile.INPUT|TextFile.FORCEABORT);
    this.newFile.open(TextFile.INPUT|TextFile.FORCEABORT);

    this.oldText = new ArrayList<>();
    this.newText = new ArrayList<>();

    /* Initialize the diff tool */
    final String clazz = MA.getCS("TOOL", "UnknownClass");
    try {
      this.diffTool = (DiffTool)Class.forName(clazz).getConstructor().newInstance(); // Update in 3.9.0 to use default constructor
    } catch(Exception e) {
      M.warning("Unable to initialize diff tool class (" + clazz + "), "
              + "attempting to use native diff tool.");
      this.diffTool = new nxm.ucl.lib.NativeDiffTool();
    }

    return NORMAL;
  }

  public int process () {
    final String oldStr = this.oldFile.read();
    final String newStr = this.newFile.read();

    if (oldStr != null) { this.oldText.add(oldStr); }
    if (newStr != null) { this.newText.add(newStr); }

    return ((oldStr == null) && (newStr == null))? FINISH: NORMAL;
  }

  @SuppressWarnings("deprecation") // for M.terminal - since 3.7.1 for NXM-2813
  public int close () {
    final String[] oldStrs = this.oldText.toArray(StringUtil.EMPTY_STRING_ARRAY);
    final String[] newStrs = this.newText.toArray(StringUtil.EMPTY_STRING_ARRAY);

    /* Apply filters. */
    try {
      if (this.fileType.equals("JAVA")) { this.filterJava(oldStrs); }
      if (this.fileType.equals("MM"  )) { this.filterMM(oldStrs);   }
      if (this.fileType.equals("TXT" )) { this.filterTxt(oldStrs);  }
    } catch(Exception e) {
      throw new MidasException("Error reading from file '"+MA.getCS("OLD")+"'", e);
    }

    try {
      if (this.fileType.equals("JAVA")) { this.filterJava(newStrs); }
      if (this.fileType.equals("MM"  )) { this.filterMM(newStrs);   }
      if (this.fileType.equals("TXT" )) { this.filterTxt(newStrs);  }
    } catch(Exception e) {
      throw new MidasException("Error reading from file '"+MA.getCS("NEW")+"'", e);
    }

    /* Get the diffs. */
    FileDiff[] diffs = null;

    try {
      diffs = this.findDiffs(oldStrs, newStrs);
    } catch(Exception e) {
      M.error("Error in running diff tool:" + e.getMessage());
    }

    /* Print the diffs. */
    M.more(Terminal.INIT);  // since 3.7.1 using M.more instead of M.terminal.more - NXM-1813
    if ((diffs == null) || (diffs.length == 0)) {
      M.more("No differences found.");
    } else {
      for (FileDiff fileDiff : diffs) {
        this.printDiff(fileDiff);
      }
    }
    M.more(Terminal.EXIT);

    /* Close the files. */
    if (this.oldFile != null) { oldFile.close(); }
    if (this.newFile != null) { newFile.close(); }

    return NORMAL;
  }

  /** This will apply the filters on Java code.
      @param lines The lines in the file.
   */
  private void filterJava (final String[] lines) {
    /* Parse the Java file. */
    Lexer.Token[] tokens = (new JavaParser(lines)).getTokens();

    /* Re-assemble the file while applying the filters. */
    int line = 0;

    if (line < tokens.length) {
      lines[line] = "";
    }

    for (int i = 0; i < tokens.length; i++) {
      String name = tokens[i].getName();
      String str  = tokens[i].getString();

      if (name.equals("BLOCK_CMT") || name.equals("LINE_CMT")) {
        if (this.ignoreComments) {
          str = "";
        }
      } else if (name.equals("WHITESPACE")) {
        if (this.ignoreWtSpace) {
          str = " ";
        }
      }

      if (name.equals("NEW_LINE")) {
        if (this.ignoreWtSpace) {
          lines[line] = lines[line].trim();
        }

        if (this.ignoreCase) {
          lines[line] = lines[line].toUpperCase();
        }

        if (this.ignoreBlankLines && (lines[line].length() == 0)) {
          lines[line] = null;
        }

        //System.out.println("" + line + ": '" + lines[line] + "'");

        line++;
        if (line < lines.length) {
          lines[line] = "";
        }
      } else {
        lines[line] += str;
      }

      if (this.ignoreCase) {
        str = str.toUpperCase();
      }

      tokens[i] = new Lexer.Token(name, str);
    }
  }


  /** This will apply the filters on a text file.
      @param lines The lines in the file.
   */
  private void filterTxt (final String[] lines) {
    for (int i = 0; i < lines.length; i++) {
      if (this.ignoreWtSpace) {
        lines[i] = lines[i].replaceAll("\\s+", " ").trim(); // Compress Wt Space
      }

      if (this.ignoreCase) {
        lines[i] = lines[i].toUpperCase();
      }

      if (this.ignoreBlankLines) {
        if(lines[i].length() == 0) {
          lines[i] = null;
        }
      }
    }
  }

  /** This will apply the filters on a text file.
      @param lines The lines in the file.
   */
  private void filterMM (final String[] lines) {
    M.warning("Not implemented for macros");

    filterTxt(lines);
  }

  /** Prints a diff.
      @param diff The diff.
   */
  @SuppressWarnings("deprecation") // for M.terminal - since 3.7.1 for NXM-2813
  private void printDiff (final FileDiff diff) {
    final boolean inserts = (diff.getLinesInserted() > 0);
    final boolean removes = (diff.getLinesRemoved()  > 0);

    /* Make the position indicator */
    StringBuffer pos = new StringBuffer();

    pos.append(diff.getOldFileStart() + (!removes? 0: 1));

    if (diff.getLinesRemoved() > 1) {
      pos.append(',');
      pos.append(diff.getOldFileStart() + diff.getLinesRemoved());
    }

    if (inserts && removes) {
      pos.append('c');
    } else if (inserts) {
      pos.append('a');
    } else {
      pos.append('d');
    }

    pos.append(diff.getNewFileStart() + (!inserts? 0: 1));

    if (diff.getLinesInserted() > 1) {
      pos.append(',');
      pos.append(diff.getNewFileStart() + diff.getLinesInserted());
    }

    /* print the position indicator */
    M.more(pos.toString());  // since 3.7.1 using M.more instead of M.terminal.more - NXM-1813

    /* print the affected lines */
    for (int i = 0; i < diff.getLinesRemoved(); i++) {
      M.more("< " + this.oldText.get(diff.getOldFileStart() + i));
    }

    if (inserts && removes) {
      M.more("---");
    }

    for (int i = 0; i < diff.getLinesInserted(); i++) {
      M.more("> " + this.newText.get(diff.getNewFileStart() + i));
    }
  }

  /** This will find the diffs from a set of diffs.
      @param oldStrs The lines from the old file (nulls indicate lines to
                     ignore when comparing).
      @param newStrs The lines from the new file (nulls indicate lines to
                     ignore when comparing).
      @return The diffs.
   */
  private FileDiff[] findDiffs (String[] oldStrs, String[] newStrs) {
    final Object[] oldRemoveNulls = removeNulls(oldStrs);
    final Object[] newRemoveNulls = removeNulls(newStrs);

    final String[]  oldNN = (String[])oldRemoveNulls[0];
    final String[]  newNN = (String[])newRemoveNulls[0];
    final Integer[] oldOT = (Integer[])oldRemoveNulls[1];
    final Integer[] newOT = (Integer[])newRemoveNulls[1];

    final FileDiff[] diffs = this.diffTool.getDiffs(oldNN, newNN);

    /* Apply the offsets for the nulls. */
    for (int i = 0; i < diffs.length; i++) {
      diffs[i] = applyOffset(diffs[i], oldOT, newOT);
    }

    return diffs;
  }

  /** Applies an offset to the line number for a diff.
      @param diff   The diff.
      @param oldOT  The offset table for the old file (0 for none).
      @param newOT  The offset table for the new file (0 for none).
      @return A new diff with the offset applied.
   */
  private static FileDiff applyOffset (final FileDiff  diff, final Integer[] oldOT, final Integer[] newOT) {
    final int oldStart = diff.getOldFileStart() + oldOT[diff.getOldFileStart()].intValue();
    final int newStart = diff.getNewFileStart() + newOT[diff.getNewFileStart()].intValue();

    return new FileDiff() {
      public int getOldFileStart()  { return oldStart; }
      public int getNewFileStart()  { return newStart; }
      public int getLinesInserted() { return diff.getLinesInserted(); }
      public int getLinesRemoved()  { return diff.getLinesRemoved();  }
    };
  }

  /** This will remove any nulls from an array.
      @param array The array.
      @return <code>results[0] =</code> The array without nulls.<br>
              <code>results[1] =</code> The offset table.
   */
  private static Object[] removeNulls (String[] array) {
    final ArrayList<Integer> offsets = new ArrayList<>();
    final ArrayList<String>  strs    = new ArrayList<>();
    int offset = 0;

    for (String str : array) {
      if (str != null) {
        strs.add(str);
        offsets.add(Integer.valueOf(offset));
      } else {
        offset++;
      }
    }

    return new Object[] { strs.toArray(new String[0]),
        offsets.toArray(new Integer[0]) };
  }
}
