/*
** Module   : TextFileComparison.java
** Abstract : encodes data allowing two text files to be compared to determine
**            if they are the same outside of excluded regions
**
** Copyright (c) 2009, Golden Code Development Corporation.
**
** -#- -I- --Date-- -----------------------Description------------------------
** 001 GES 20090218 Encodes data allowing two text files to be compared, to
**                  determine if they are the same outside of excluded regions.
*/

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

import java.io.*;
import java.util.*;
import com.goldencode.harness.transport.*;

/**
 * Encodes data allowing two text files to be compared to determine if they
 * are the same outside of excluded regions. Comparisons are done on a line by
 * line basis (delimited by new line characters but it does ignore those new
 * line characters so platform-specific differences are not an issue).
 */
public class TextFileComparison
extends FileComparison
{
   /** Short name for the activity. */
   private static final String NAME = "TEXT-FILE-COMPARISON";
   
   /** Format specification for mismatching data. */
   private static final String FAIL_SPEC = "Mismatched data at absolute row " +
                                           "%d, relative row %d, page # %d, " +
                                           "page size %d, last page %b, "     +
                                           "column index %d. Expected '%c' "  +
                                           "(0x%02X) and found '%c' (0x%02X).";
                                           
   /** Format specification for an unexpected EOF. */
   private static final String EOF_SPEC = "Unexpected EOF in %s at %s # %d.";
   
   /** Format specification for a difference in page sizes. */
   private static final String PAGE_SZ_SPEC = "Different page sizes for page " +
                                              " # %d (baseline page size = %d" +
                                              " and actual page size = %d).";
   
   /** Flag to enable pagination mode. */
   private boolean paging = false;
   
   /**
    * Create an instance.
    *
    * @param    baseline
    *           Identifies the file containing the expected data.
    * @param    actual
    *           Identifies the file containing the actual data generated during
    *           the test.
    * @param    downloadDir 
    *           The local directory in which a downloaded file will be placed
    *           or <code>null</code> if no downloading should occur.
    * @param    sftp
    *           Helper for downloading the comparison file via SFTP. The
    *           <code>actual</code> parameter must specify the absolute or
    *           relative filename of the file on the server system. The file
    *           will be downloaded into the download directory as specified
    *           in the project index.
    * @param    excludes
    *           A list of portions of character lines to be ignored for
    *           comparison purposes or <code>null</code> if there are no
    *           exclusion regions.
    */
   public TextFileComparison(String         baseline,
                             String         actual,
                             String         downloadDir,
                             SFTPSession    sftp,
                             RowExclusion[] excludes)
   {
      super(NAME, baseline, actual, downloadDir, sftp, excludes);
      
      if (excludes != null)
      {
         for (RowExclusion next : excludes)
         {
            if (!(next instanceof AbsoluteRowExclusion))
            {
               // anything other than absolute mode needs pagination
               paging = true;
            }
         }
      }
   }
   
   /**
    * Detect if the actual data file generated during testing matches the
    * given baseline file outside of any specified exclusion regions.
    *
    * @param    base
    *           Baseline file data.
    * @param    other
    *           Comparison file data.
    *
    * @return   <code>null</code> if the actual file is the same as the
    *           baseline file (after ignoring any exclusion regions) or an
    *           error string on any deviation.
    */
   public String compare(File base, File other)
   throws Exception
   {
      FileReader     baseFr  = new FileReader(base);
      BufferedReader baseBr  = new BufferedReader(baseFr);
      FileReader     otherFr = new FileReader(other);
      BufferedReader otherBr = new BufferedReader(otherFr);
      
      String reason    = null;
      String baseLine  = null;
      String otherLine = null;
      
      int lineNum = 0;
      
      if (!paging)
      {
         baseLine  = baseBr.readLine();
         otherLine = otherBr.readLine();
         
         while (baseLine != null && otherLine != null)
         {
            reason = compareLines(baseLine,
                                  otherLine,
                                  lineNum,
                                  -1,
                                  -1,
                                  -1,
                                  false);
            
            if (reason != null)
            {
               return reason;
            }
            
            baseLine  = baseBr.readLine();
            otherLine = otherBr.readLine();
            
            lineNum++;
         }
         
         if (baseLine != null || otherLine != null)
         {
            String err = (baseLine != null) ? "baseline" : "actual";
            return String.format(EOF_SPEC, err, "line", lineNum);
         }
      }
      else
      {
         // pagination mode
         
         PageState baseState  = new PageState(baseBr);
         PageState otherState = new PageState(otherBr);
         
         int     relNum  = 0;
         int     pageNum = 0;
         int     pageSz  = 0;
         int     pageSz2 = 0;
         boolean isLast  = false;
         boolean isLast2 = false;
         
         while (!isLast)
         {
            // reset page-relative row number
            relNum = 0;
            
            // first time through this is 1 which is OK because page numbers
            // are 1-based
            pageNum++;
            
            // clear our page cache
            ArrayList<String> baseList  = new ArrayList<String>();
            ArrayList<String> otherList = new ArrayList<String>();
            
            // read a page from each file
            isLast  = baseState.getNextPage(baseList);
            isLast2 = otherState.getNextPage(otherList);
            
            if ((isLast && !isLast2) || (!isLast && isLast2))
            {
               String err = isLast2 ? "baseline" : "actual";
               return String.format(EOF_SPEC, err, "page", pageNum);
            }
            
            pageSz  = baseList.size();
            pageSz2 = otherList.size();
            
            if (pageSz != pageSz2)
            {
               return String.format(PAGE_SZ_SPEC, pageNum, pageSz, pageSz2);
            }
            
            // process the page of data (compare each line)
            for (int x = 0; x < pageSz; x++)
            {
               baseLine  = baseList.get(x);
               otherLine = otherList.get(x);
               
               reason = compareLines(baseLine,
                                     otherLine,
                                     lineNum,
                                     relNum,
                                     pageNum,
                                     pageSz,
                                     isLast);
               
               if (reason != null)
               {
                  return reason;
               }
               
               lineNum++;
               relNum++;
            }
         }
      }
      
      return null;
   }
   
   /**
    * Compare the specific lines using the row and pagination data to process
    * all exclusions.
    *
    * @param    baseLine
    *           Expected data.
    * @param    otherLine
    *           Comparison data.
    * @param    lineNum
    *           Absolute row number as calculated from the beginning of the
    *           file.
    * @param    relNum
    *           Relative row number as calculated from the beginning of the
    *           current page.
    * @param    pageNum
    *           Current page number (0-based).
    * @param    pageSz
    *           Size (in number of lines) of the current page.
    * @param    isLast   
    *           <code>true</code> if the current page is the last page in the
    *           file.
    *
    * @return   <code>null</code> if the actual file is the same as the
    *           baseline file (after ignoring any exclusion regions) or an
    *           error string on any deviation.
    */
   private String compareLines(String  baseLine,
                               String  otherLine,
                               int     lineNum,
                               int     relNum,
                               int     pageNum,
                               int     pageSz,
                               boolean isLast)
   {
      int baseLen  = baseLine.length();
      int otherLen = otherLine.length();
      
      if (baseLen != otherLen)
      {
         if (!ignoreSize(lineNum, relNum, pageNum, pageSz, isLast))
         {
            return String.format("Line size mismatch (line # = %d, base = " +
                                 "%d, actual = %d).",
                                 lineNum,
                                 baseLen,
                                 otherLen);
         }
      }
      
      // compare the lines
      for (int i = 0; i < baseLine.length(); i++)
      {
         if (exclude(lineNum, relNum, pageNum, pageSz, isLast, i))
         {
            // excluded row and column
            continue;
         }
         
         char baseCh  = baseLine.charAt(i);
         char otherCh = otherLine.charAt(i);
         
         if (baseCh != otherCh)
         {
            // mismatching data
            return String.format(FAIL_SPEC,
                                 lineNum,
                                 relNum,
                                 pageNum,
                                 pageSz,
                                 isLast,
                                 i,
                                 baseCh,
                                 (int) baseCh,
                                 otherCh,
                                 (int) otherCh);
         }
      }
      
      return null;
   }
   
   /**
    * Report if size differences for the given row should be ignored.
    *
    * @param    absNum
    *           Absolute row number as calculated from the beginning of the
    *           file.
    * @param    relNum
    *           Relative row number as calculated from the beginning of the
    *           current page.
    * @param    pageNum
    *           Current page number (0-based).
    * @param    pageSz
    *           Size (in number of lines) of the current page.
    * @param    isLast   
    *           <code>true</code> if the current page is the last page in the
    *           file.
    *
    * @return   <code>true</code> if size differences for the given row should
    *           be ignored.
    */
   public boolean ignoreSize(int     absNum,
                             int     relNum,
                             int     pageNum,
                             int     pageSz,
                             boolean isLast)
   {
      RowExclusion[] excludes = (RowExclusion[]) this.excludes;
      
      if (excludes != null)
      {
         for (int i = 0; i < excludes.length; i++)
         {
            if (excludes[i].shouldIgnoreSize(absNum,
                                             relNum,
                                             pageNum,
                                             pageSz,
                                             isLast))
            {
               return true;
            }
         }
      }
      
      return false;
   }
   
   /**
    * Report if the given row and column index position is contained within the list
    * of excluded regions.
    *
    * @param    absNum
    *           Absolute row number as calculated from the beginning of the
    *           file.
    * @param    relNum
    *           Relative row number as calculated from the beginning of the
    *           current page.
    * @param    pageNum
    *           Current page number (0-based).
    * @param    pageSz
    *           Size (in number of lines) of the current page.
    * @param    isLast   
    *           <code>true</code> if the current page is the last page in the
    *           file.
    * @param    col
    *           The 0-based index position of the character being checked on
    *           the current line.
    *
    * @return   <code>true</code> if the given row and column index is
    *           contained inside any of the excluded regions.
    */
   public boolean exclude(int     absNum,
                          int     relNum,
                          int     pageNum,
                          int     pageSz,
                          boolean isLast,
                          int     col)
   {
      RowExclusion[] excludes = (RowExclusion[]) this.excludes;
      
      if (excludes != null)
      {
         for (int i = 0; i < excludes.length; i++)
         {
            if (excludes[i].contains(absNum,
                                     relNum,
                                     pageNum,
                                     pageSz,
                                     isLast,
                                     col))
            {
               return true;
            }
         }
      }
      
      return false;
   }
   
   /**
    * Store pagination state in between calls to read a page.
    */
   private class PageState
   {
      /** Input source. */
      private BufferedReader br = null;
      
      /** Any data left over from reading the previous page. */
      private String partial = null;
      
      /**
       * Create an instance to process pages for the given stream.
       *
       * @param    br
       *           The input source.
       */
      public PageState(BufferedReader br)
      {
         this.br = br;
      }
      
      /**
       * Read the next page into the given list. Pages are delimited by form
       * feed characters. Lines are delimited by new line characters. Form
       * feeds (and new line characters) are not stored.
       *
       * @param    list
       *           The container to hold the next page, with one line per
       *           element in the list.
       *
       * @return   <code>true</code> if the page just read is the last page.
       * 
       * @throws   IOException
       *           if an I/O error occurs while reading.
       */
      public boolean getNextPage(List<String> list)
      throws IOException
      {
         String line = null;
         
         // read the first line for the page
         if (partial != null)
         {
            // reuse the read data that was not part of the last page
            line = partial;
            partial = null;
         }
         else
         {
            // no partial data, read the next line from the stream
            line = br.readLine();
         }
         
         loop:
         while (line != null)
         {
            int pos = line.indexOf('\f');
            
            if (pos < 0)
            {
               // no embedded form feed, add the whole line to the list
               list.add(line);
            }
            else
            {
               // we must end the current page
               
               int len = line.length();
               
               if (pos == 0)
               {
                  // first character starts a new page
                  if (len == 1)
                  {
                     // this is the only character in the line
                     
                     // check if there is another page
                     partial = br.readLine();
                     
                     // if this is also the last page
                     if (partial == null)
                        break loop;
                  }
                  else
                  {
                     partial = line.substring(1);
                  }
               }
               else if (pos == (len - 1))
               {
                  // last character starts a new page
                  list.add(line.substring(0, (len - 1)));
                  
                  // check if there is another page
                  partial = br.readLine();
                  
                  // if this is also the last page
                  if (partial == null)
                     break loop;
               }
               else
               {
                  // there are contents for 2 pages in this line
                  list.add(line.substring(0, pos));
                  partial = line.substring(pos + 1);
               }
               
               return false;
            }
            
            line = br.readLine();
         }
         
         return true;
      }
   }
}
