/*
 *    This program is free software; you can redistribute it and/or modify
 *    it under the terms of the GNU General Public License as published by
 *    the Free Software Foundation; either version 2 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 General Public License for more details.
 *
 *    You should have received a copy of the GNU General Public License
 *    along with this program; if not, write to the Free Software
 *    Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 */

/*
 * ArffTableModel.java
 * Copyright (C) 2005 University of Waikato, Hamilton, New Zealand
 *
 */

package weka.gui.arffviewer;

import weka.core.Attribute;
import weka.core.Instance;
import weka.core.Instances;
import weka.core.Undoable;
import weka.core.Utils;
import weka.core.converters.AbstractFileLoader;
import weka.core.converters.ConverterUtils;
import weka.filters.Filter;
import weka.filters.unsupervised.attribute.Reorder;
import weka.gui.ComponentHelper;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Vector;

import javax.swing.JOptionPane;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import javax.swing.table.TableModel;

/**
 * The model for the Arff-Viewer.
 *
 *
 * @author FracPete (fracpete at waikato dot ac dot nz)
 * @version $Revision: 5987 $ 
 */
public class ArffTableModel 
  implements TableModel, Undoable {
  
  /** the listeners */
  private HashSet m_Listeners;
  /** the data */
  private Instances m_Data;
  /** whether notfication is enabled */
  private boolean m_NotificationEnabled;
  /** whether undo is active */
  private boolean m_UndoEnabled;
  /** whether to ignore changes, i.e. not adding to undo history */
  private boolean m_IgnoreChanges;
  /** the undo list (contains temp. filenames) */
  private Vector m_UndoList;
  /** whether the table is read-only */
  private boolean m_ReadOnly;
  
  /**
   * performs some initialization
   */
  private ArffTableModel() {
    super();
    
    m_Listeners           = new HashSet();
    m_Data                = null;
    m_NotificationEnabled = true;
    m_UndoList            = new Vector();
    m_IgnoreChanges       = false;
    m_UndoEnabled         = true;
    m_ReadOnly            = false;
  }
  
  /**
   * initializes the object and loads the given file
   * 
   * @param filename	the file to load
   */
  public ArffTableModel(String filename) {
    this();
    
    if ( (filename != null) && (!filename.equals("")) )
      loadFile(filename);
  }
  
  /**
   * initializes the model with the given data
   * 
   * @param data 	the data to use
   */
  public ArffTableModel(Instances data) {
    this();
    
    this.m_Data = data;
  }

  /**
   * returns whether the notification of changes is enabled
   * 
   * @return 		true if notification of changes is enabled
   */
  public boolean isNotificationEnabled() {
    return m_NotificationEnabled;
  }
  
  /**
   * sets whether the notification of changes is enabled
   * 
   * @param enabled	enables/disables the notification
   */
  public void setNotificationEnabled(boolean enabled) {
    m_NotificationEnabled = enabled;
  }

  /**
   * returns whether undo support is enabled
   * 
   * @return 		true if undo support is enabled
   */
  public boolean isUndoEnabled() {
    return m_UndoEnabled;
  }
  
  /**
   * sets whether undo support is enabled
   * 
   * @param enabled	whether to enable/disable undo support
   */
  public void setUndoEnabled(boolean enabled) {
    m_UndoEnabled = enabled;
  }

  /**
   * returns whether the model is read-only
   * 
   * @return 		true if model is read-only
   */
  public boolean isReadOnly() {
    return m_ReadOnly;
  }
  
  /**
   * sets whether the model is read-only
   * 
   * @param value	if true the model is set to read-only
   */
  public void setReadOnly(boolean value) {
    m_ReadOnly = value;
  }
  
  /**
   * loads the specified ARFF file
   * 
   * @param filename	the file to load
   */
  private void loadFile(String filename) {
    AbstractFileLoader          loader;
    
    loader = ConverterUtils.getLoaderForFile(filename);
    
    if (loader != null) {
      try {
        loader.setFile(new File(filename));
        m_Data = loader.getDataSet();
      }
      catch (Exception e) {
        ComponentHelper.showMessageBox(
            null, 
            "Error loading file...", 
            e.toString(), 
            JOptionPane.OK_CANCEL_OPTION,
            JOptionPane.ERROR_MESSAGE );
        System.out.println(e);
        m_Data = null;
      }
    }
  }
  
  /**
   * sets the data
   * 
   * @param data	the data to use
   */
  public void setInstances(Instances data) {
    m_Data = data;
  }
  
  /**
   * returns the data
   * 
   * @return		the current data
   */
  public Instances getInstances() {
    return m_Data;
  }
  
  /**
   * returns the attribute at the given index, can be NULL if not an attribute
   * column
   * 
   * @param columnIndex		the index of the column
   * @return			the attribute at the position
   */
  public Attribute getAttributeAt(int columnIndex) {
    if ( (columnIndex > 0) && (columnIndex < getColumnCount()) )
      return m_Data.attribute(columnIndex - 1);
    else
      return null;
  }
  
  /**
   * returns the TYPE of the attribute at the given position
   * 
   * @param columnIndex		the index of the column
   * @return			the attribute type
   */
  public int getType(int columnIndex) {
    return getType(0, columnIndex);
  }
  
  /**
   * returns the TYPE of the attribute at the given position
   * 
   * @param rowIndex		the index of the row
   * @param columnIndex		the index of the column
   * @return			the attribute type
   */
  public int getType(int rowIndex, int columnIndex) {
    int            result;
    
    result = Attribute.STRING;
    
    if (    (rowIndex >= 0) && (rowIndex < getRowCount())
         && (columnIndex > 0) && (columnIndex < getColumnCount()) )
      result = m_Data.instance(rowIndex).attribute(columnIndex - 1).type();
    
    return result;
  }
  
  /**
   * deletes the attribute at the given col index. notifies the listeners.
   * 
   * @param columnIndex     the index of the attribute to delete
   */
  public void deleteAttributeAt(int columnIndex) {
    deleteAttributeAt(columnIndex, true);
  }
  
  /**
   * deletes the attribute at the given col index
   * 
   * @param columnIndex     the index of the attribute to delete
   * @param notify          whether to notify the listeners
   */
  public void deleteAttributeAt(int columnIndex, boolean notify) {
    if ( (columnIndex > 0) && (columnIndex < getColumnCount()) ) {
      if (!m_IgnoreChanges)
        addUndoPoint();
      m_Data.deleteAttributeAt(columnIndex - 1);
      if (notify) 
        notifyListener(new TableModelEvent(this, TableModelEvent.HEADER_ROW));
    }
  }
  
  /**
   * deletes the attributes at the given indices
   * 
   * @param columnIndices	the column indices
   */
  public void deleteAttributes(int[] columnIndices) {
    int            i;
    
    Arrays.sort(columnIndices);
    
    addUndoPoint();

    m_IgnoreChanges = true;
    for (i = columnIndices.length - 1; i >= 0; i--)
      deleteAttributeAt(columnIndices[i], false);
    m_IgnoreChanges = false;

    notifyListener(new TableModelEvent(this, TableModelEvent.HEADER_ROW));
  }
  
  /**
   * renames the attribute at the given col index
   * 
   * @param columnIndex		the index of the column
   * @param newName		the new name of the attribute
   */
  public void renameAttributeAt(int columnIndex, String newName) {
    if ( (columnIndex > 0) && (columnIndex < getColumnCount()) ) {
      addUndoPoint();
      m_Data.renameAttribute(columnIndex - 1, newName);
      notifyListener(new TableModelEvent(this, TableModelEvent.HEADER_ROW));
    }
  }
  
  /**
   * sets the attribute at the given col index as the new class attribute, i.e.
   * it moves it to the end of the attributes
   * 
   * @param columnIndex		the index of the column
   */
  public void attributeAsClassAt(int columnIndex) {
    Reorder     reorder;
    String      order;
    int         i;
    
    if ( (columnIndex > 0) && (columnIndex < getColumnCount()) ) {
      addUndoPoint();
      
      try {
        // build order string (1-based!)
        order = "";
        for (i = 1; i < m_Data.numAttributes() + 1; i++) {
          // skip new class
          if (i == columnIndex)
            continue;
          
          if (!order.equals(""))
            order += ",";
          order += Integer.toString(i);
        }
        if (!order.equals(""))
          order += ",";
        order += Integer.toString(columnIndex);
        
        // process data
        reorder = new Reorder();
        reorder.setAttributeIndices(order);
        reorder.setInputFormat(m_Data);
        m_Data = Filter.useFilter(m_Data, reorder);
        
        // set class index
        m_Data.setClassIndex(m_Data.numAttributes() - 1);
      }
      catch (Exception e) {
        e.printStackTrace();
        undo();
      }
      
      notifyListener(new TableModelEvent(this, TableModelEvent.HEADER_ROW));
    }
  }
  
  /**
   * deletes the instance at the given index
   * 
   * @param rowIndex		the index of the row
   */
  public void deleteInstanceAt(int rowIndex) {
    deleteInstanceAt(rowIndex, true);
  }
  
  /**
   * deletes the instance at the given index
   * 
   * @param rowIndex		the index of the row
   * @param notify		whether to notify the listeners
   */
  public void deleteInstanceAt(int rowIndex, boolean notify) {
    if ( (rowIndex >= 0) && (rowIndex < getRowCount()) ) {
      if (!m_IgnoreChanges)
        addUndoPoint();
      m_Data.delete(rowIndex);
      if (notify)
        notifyListener(
            new TableModelEvent(
                this, rowIndex, rowIndex, 
                TableModelEvent.ALL_COLUMNS, TableModelEvent.DELETE));
    }
  }
  
  /**
   * deletes the instances at the given positions
   * 
   * @param rowIndices		the indices to delete
   */
  public void deleteInstances(int[] rowIndices) {
    int               i;
    
    Arrays.sort(rowIndices);
    
    addUndoPoint();
    
    m_IgnoreChanges = true;
    for (i = rowIndices.length - 1; i >= 0; i--)
      deleteInstanceAt(rowIndices[i], false);
    m_IgnoreChanges = false;

    notifyListener(
        new TableModelEvent(
            this, rowIndices[0], rowIndices[rowIndices.length - 1], 
            TableModelEvent.ALL_COLUMNS, TableModelEvent.DELETE));
  }
  
  /**
   * sorts the instances via the given attribute
   * 
   * @param columnIndex		the index of the column
   */
  public void sortInstances(int columnIndex) {
    if ( (columnIndex > 0) && (columnIndex < getColumnCount()) ) {
      addUndoPoint();
      m_Data.sort(columnIndex - 1);
      notifyListener(new TableModelEvent(this));
    }
  }
  
  /**
   * returns the column of the given attribute name, -1 if not found
   * 
   * @param name		the name of the attribute
   * @return			the column index or -1 if not found
   */
  public int getAttributeColumn(String name) {
    int            i;
    int            result;
    
    result = -1;
    
    for (i = 0; i < m_Data.numAttributes(); i++) {
      if (m_Data.attribute(i).name().equals(name)) {
        result = i + 1;
        break;
      }
    }
    
    return result;
  }
  
  /**
   * returns the most specific superclass for all the cell values in the 
   * column (always String)
   * 
   * @param columnIndex		the column index
   * @return			the class of the column
   */
  public Class getColumnClass(int columnIndex) {
    Class       result;
    
    result = null;
    
    if ( (columnIndex >= 0) && (columnIndex < getColumnCount()) ) {
      if (columnIndex == 0)
        result = Integer.class;
      else if (getType(columnIndex) == Attribute.NUMERIC)
        result = Double.class;
      else
        result = String.class;   // otherwise no input of "?"!!!
    }
    
    return result;
  }
  
  /**
   * returns the number of columns in the model
   * 
   * @return		the number of columns
   */
  public int getColumnCount() {
    int         result;
    
    result = 1;
    if (m_Data != null)
      result += m_Data.numAttributes();
    
    return result;
  }
  
  /**
   * checks whether the column represents the class or not
   * 
   * @param columnIndex		the index of the column
   * @return			true if the column is the class attribute
   */
  private boolean isClassIndex(int columnIndex) {
    boolean        result;
    int            index;
    
    index  = m_Data.classIndex();
    result =    ((index == - 1) && (m_Data.numAttributes() == columnIndex))
             || (index == columnIndex - 1);
    
    return result;
  }
  
  /**
   * returns the name of the column at columnIndex
   * 
   * @param columnIndex		the index of the column
   * @return			the name of the column
   */
  public String getColumnName(int columnIndex) {
    String      result;
    
    result = "";
    
    if ( (columnIndex >= 0) && (columnIndex < getColumnCount()) ) {
      if (columnIndex == 0) {
        result = "<html><center>No.<br><font size=\"-2\">&nbsp;</font></center></html>";
      }
      else {
        if (m_Data != null) {
          if ( (columnIndex - 1 < m_Data.numAttributes()) ) {
            result = "<html><center>";
            // name
            if (isClassIndex(columnIndex))
              result +=   "<b>" 
                + m_Data.attribute(columnIndex - 1).name() 
                + "</b>";
            else
              result += m_Data.attribute(columnIndex - 1).name();
            
            // attribute type
            switch (getType(columnIndex)) {
              case Attribute.DATE: 
                result += "<br><font size=\"-2\">Date</font>";
                break;
              case Attribute.NOMINAL:
                result += "<br><font size=\"-2\">Nominal</font>";
                break;
              case Attribute.STRING:
                result += "<br><font size=\"-2\">String</font>";
                break;
              case Attribute.NUMERIC:
                result += "<br><font size=\"-2\">Numeric</font>";
                break;
              case Attribute.RELATIONAL:
                result += "<br><font size=\"-2\">Relational</font>";
                break;
              default:
                result += "<br><font size=\"-2\">???</font>";
            }
            
            result += "</center></html>";
          }
        }
      }
    }
    
    return result;
  }
  
  /**
   * returns the number of rows in the model
   * 
   * @return		the number of rows
   */
  public int getRowCount() {
    if (m_Data == null)
      return 0;
    else
      return m_Data.numInstances(); 
  }
  
  /**
   * checks whether the value at the given position is missing
   * 
   * @param rowIndex		the row index
   * @param columnIndex		the column index
   * @return			true if the value at the position is missing
   */
  public boolean isMissingAt(int rowIndex, int columnIndex) {
    boolean           result;
    
    result = false;
    
    if (    (rowIndex >= 0) && (rowIndex < getRowCount())
         && (columnIndex > 0) && (columnIndex < getColumnCount()) )
      result = (m_Data.instance(rowIndex).isMissing(columnIndex - 1));
    
    return result;
  }
  
  /**
   * returns the double value of the underlying Instances object at the
   * given position, -1 if out of bounds
   * 
   * @param rowIndex		the row index
   * @param columnIndex		the column index
   * @return			the underlying value in the Instances object
   */
  public double getInstancesValueAt(int rowIndex, int columnIndex) {
    double	result;
    
    result = -1;
    
    if (    (rowIndex >= 0) && (rowIndex < getRowCount())
         && (columnIndex > 0) && (columnIndex < getColumnCount()) )
      result = m_Data.instance(rowIndex).value(columnIndex - 1);
    
    return result;
  }
  
  /**
   * returns the value for the cell at columnindex and rowIndex
   * 
   * @param rowIndex		the row index
   * @param columnIndex		the column index
   * @return 			the value at the position
   */
  public Object getValueAt(int rowIndex, int columnIndex) {
    Object            result;
    String            tmp;
    
    result = null;
    
    if (    (rowIndex >= 0) && (rowIndex < getRowCount())
        && (columnIndex >= 0) && (columnIndex < getColumnCount()) ) {
      if (columnIndex == 0) {
        result = new Integer(rowIndex + 1);
      }
      else {
        if (isMissingAt(rowIndex, columnIndex)) {
          result = null;
        }
        else {
          switch (getType(columnIndex)) {
            case Attribute.DATE: 
            case Attribute.NOMINAL:
            case Attribute.STRING:
            case Attribute.RELATIONAL:
              result = m_Data.instance(rowIndex).stringValue(columnIndex - 1);
              break;
            case Attribute.NUMERIC:
              result = new Double(m_Data.instance(rowIndex).value(columnIndex - 1));
              break;
            default:
              result = "-can't display-";
          }
        }
      }
    }
    
    if (getType(columnIndex) != Attribute.NUMERIC) {
      if (result != null) {
        // does it contain "\n" or "\r"? -> replace with red html tag
        tmp = result.toString();
        if ( (tmp.indexOf("\n") > -1) || (tmp.indexOf("\r") > -1) ) {
          tmp    = tmp.replaceAll("\\r\\n", "<font color=\"red\"><b>\\\\r\\\\n</b></font>");
          tmp    = tmp.replaceAll("\\r", "<font color=\"red\"><b>\\\\r</b></font>");
          tmp    = tmp.replaceAll("\\n", "<font color=\"red\"><b>\\\\n</b></font>");
          result = "<html>" + tmp + "</html>";
        }
      }
    }
    
    return result;
  }
  
  /**
   * returns true if the cell at rowindex and columnindexis editable
   * 
   * @param rowIndex		the index of the row
   * @param columnIndex		the index of the column
   * @return			true if the cell is editable
   */
  public boolean isCellEditable(int rowIndex, int columnIndex) {
    return (columnIndex > 0) && !isReadOnly();
  }
  
  /**
   * sets the value in the cell at columnIndex and rowIndex to aValue.
   * but only the value and the value can be changed
   * 
   * @param aValue		the new value
   * @param rowIndex		the row index
   * @param columnIndex		the column index
   */
  public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
    setValueAt(aValue, rowIndex, columnIndex, true);
  }
  
  /**
   * sets the value in the cell at columnIndex and rowIndex to aValue.
   * but only the value and the value can be changed
   * 
   * @param aValue		the new value
   * @param rowIndex		the row index
   * @param columnIndex		the column index
   * @param notify		whether to notify the listeners
   */
  public void setValueAt(Object aValue, int rowIndex, int columnIndex, boolean notify) {
    int            type;
    int            index;
    String         tmp;
    Instance       inst;
    Attribute      att;
    Object         oldValue;
    
    if (!m_IgnoreChanges)
      addUndoPoint();
    
    oldValue = getValueAt(rowIndex, columnIndex);
    type     = getType(rowIndex, columnIndex);
    index    = columnIndex - 1;
    inst     = m_Data.instance(rowIndex);
    att      = inst.attribute(index);
    
    // missing?
    if (aValue == null) {
      inst.setValue(index, Utils.missingValue());
    }
    else {
      tmp = aValue.toString();
      
      switch (type) {
        case Attribute.DATE:
          try {
            att.parseDate(tmp);
            inst.setValue(index, att.parseDate(tmp));
          }
          catch (Exception e) {
            // ignore
          }
          break;
      
        case Attribute.NOMINAL:
          if (att.indexOfValue(tmp) > -1)
            inst.setValue(index, att.indexOfValue(tmp));
          break;
      
        case Attribute.STRING:
          inst.setValue(index, tmp);
          break;
      
        case Attribute.NUMERIC:
          try {
            Double.parseDouble(tmp);
            inst.setValue(index, Double.parseDouble(tmp));
          }
          catch (Exception e) {
            // ignore
          }
          break;
          
        case Attribute.RELATIONAL:
          try {
            inst.setValue(index, inst.attribute(index).addRelation((Instances) aValue));
          }
          catch (Exception e) {
            // ignore
          }
          break;
          
        default:
          throw new IllegalArgumentException("Unsupported Attribute type: " + type + "!");
      }
    }
    
    // notify only if the value has changed!
    if (notify && (!("" + oldValue).equals("" + aValue)) )
        notifyListener(new TableModelEvent(this, rowIndex, columnIndex));
  }
  
  /**
   * adds a listener to the list that is notified each time a change to data 
   * model occurs
   * 
   * @param l		the listener to add
   */
  public void addTableModelListener(TableModelListener l) {
    m_Listeners.add(l);
  }
  
  /**
   * removes a listener from the list that is notified each time a change to
   * the data model occurs
   * 
   * @param l		the listener to remove
   */
  public void removeTableModelListener(TableModelListener l) {
    m_Listeners.remove(l);
  }
  
  /**
   * notfies all listener of the change of the model
   * 
   * @param e		the event to send to the listeners
   */
  public void notifyListener(TableModelEvent e) {
    Iterator                iter;
    TableModelListener      l;

    // is notification enabled?
    if (!isNotificationEnabled())
      return;
    
    iter = m_Listeners.iterator();
    while (iter.hasNext()) {
      l = (TableModelListener) iter.next();
      l.tableChanged(e);
    }
  }

  /**
   * removes the undo history
   */
  public void clearUndo() {
    m_UndoList = new Vector();
  }
  
  /**
   * returns whether an undo is possible, i.e. whether there are any undo points
   * saved so far
   * 
   * @return returns TRUE if there is an undo possible 
   */
  public boolean canUndo() {
    return !m_UndoList.isEmpty();
  }
  
  /**
   * undoes the last action
   */
  public void undo() {
    File                  tempFile;
    Instances             inst;
    ObjectInputStream     ooi;
    
    if (canUndo()) {
      // load file
      tempFile = (File) m_UndoList.get(m_UndoList.size() - 1);
      try {
        // read serialized data
        ooi = new ObjectInputStream(new BufferedInputStream(new FileInputStream(tempFile)));
        inst = (Instances) ooi.readObject();
        ooi.close();
        
        // set instances
        setInstances(inst);
        notifyListener(new TableModelEvent(this, TableModelEvent.HEADER_ROW));
        notifyListener(new TableModelEvent(this));
      }
      catch (Exception e) {
        e.printStackTrace();
      }
      tempFile.delete();
      
      // remove from undo
      m_UndoList.remove(m_UndoList.size() - 1);
    }
  }
  
  /**
   * adds an undo point to the undo history, if the undo support is enabled
   * @see #isUndoEnabled()
   * @see #setUndoEnabled(boolean)
   */
  public void addUndoPoint() {
    File                  tempFile;
    ObjectOutputStream    oos;

    // undo support currently on?
    if (!isUndoEnabled())
      return;
    
    if (getInstances() != null) {
      try {
        // temp. filename
        tempFile = File.createTempFile("arffviewer", null);
        tempFile.deleteOnExit();
        
        // serialize instances
        oos = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(tempFile)));
        oos.writeObject(getInstances());
        oos.flush();
        oos.close();
        
        // add to undo list
        m_UndoList.add(tempFile);
      }
      catch (Exception e) {
        e.printStackTrace();
      }
    }
  }
}
