package prefuse.data;

import java.util.HashMap;

import prefuse.util.PrefuseLib;

/**
 * <p>The Schema class represents a description of a Table's columns, including
 * column names, data types, and default values. New Table
 * instances can be created directly from Schema objects through the use of
 * the {@link #instantiate()} method. If a schema is subsequently changed,
 * instantiated table instances are not affected, keeping their original
 * schema.</p>
 * 
 * <p>Schema instances can be locked to prevent further changes. Any attempt
 * to alter a locked schema will result in a runtime exception being thrown.
 * If a schema is not locked, clients are free to add new columns and
 * edit default values.</p>
 * 
 * @author <a href="http://jheer.org">jeffrey heer</a>
 */
public class Schema implements Cloneable {

    private String[] m_names;
    private Class[]  m_types;
    private Object[] m_dflts;
    private HashMap  m_lookup;
    private int      m_size;
    private boolean  m_locked;
    
    // ------------------------------------------------------------------------
    // Constructors
    
    /**
     * Creates a new empty schema.
     */
    public Schema() {
        this(10);
    }
    
    /**
     * Creates a new empty schema with a starting capacity for a given number
     * of columns.
     * @param ncols the number of columns in this schema
     */
    public Schema(int ncols) {
        m_names = new String[ncols];
        m_types = new Class[ncols];
        m_dflts = new Object[ncols];
        m_size = 0;
        m_locked = false;
    }
    
    /**
     * Create a new schema consisting of the given column names and types.
     * @param names the column names
     * @param types the column types (as Class instances)
     */
    public Schema(String[] names, Class[] types) {
        this(names.length);
        
        // check the schema validity
        if ( names.length != types.length ) {
            throw new IllegalArgumentException(
                "Input arrays should be the same length");
        }
        for ( int i=0; i<names.length; ++i ) {
            addColumn(names[i], types[i], null);
        }
    }
    
    /**
     * Create a new schema consisting of the given column names, types, and
     * default column values.
     * @param names the column names
     * @param types the column types (as Class instances)
     * @param defaults the default values for each column
     */
    public Schema(String[] names, Class[] types, Object[] defaults) {
        this(names.length);
        
        // check the schema validity
        if ( names.length != types.length || 
             types.length != defaults.length )
        {
            throw new IllegalArgumentException(
                "Input arrays should be the same length");
        }
        for ( int i=0; i<names.length; ++i ) {
            addColumn(names[i], types[i], defaults[i]);
        }
    }
    
    /**
     * Creates a copy of this Schema. This might be useful for creating
     * extended schemas from a shared base schema. Cloned copies
     * of a locked Schema will not inherit the locked status.
     * @see java.lang.Object#clone()
     */
    public Object clone() {
        Schema s = new Schema(m_size);
        for ( int i=0; i<m_size; ++i ) {
            s.addColumn(m_names[i], m_types[i], m_dflts[i]);
        }
        return s;
    }
    
    /**
     * Lazily construct the lookup table for this schema. Used to
     * accelerate name-based lookups of schema information.
     */
    protected void initLookup() {
        m_lookup = new HashMap();
        for ( int i=0; i<m_names.length; ++i ) {
            m_lookup.put(m_names[i], new Integer(i));
        }
    }
    
    // ------------------------------------------------------------------------
    // Accessors / Mutators

    /**
     * Locks the schema, preventing any additional changes. Locked schemas
     * can not be unlocked! Cloned copies of a locked schema will not inherit
     * this locked status.
     * @return a pointer to this schema
     */
    public Schema lockSchema() {
        m_locked = true;
        return this;
    }
    
    /**
     * Indicates if this schema is locked. Locked schemas can not be edited.
     * @return true if this schema is locked, false otherwise
     */
    public boolean isLocked() {
        return m_locked;
    }    
    
    /**
     * Add a column to this schema.
     * @param name the column name
     * @param type the column type (as a Class instance)
     * @throws IllegalArgumentException is either name or type are null or
     * the name already exists in this schema.
     */
    public void addColumn(String name, Class type) {
        addColumn(name, type, null);
    }
    
    /**
     * Add a column to this schema.
     * @param name the column name
     * @param type the column type (as a Class instance)
     * @throws IllegalArgumentException is either name or type are null or
     * the name already exists in this schema.
     */
    public void addColumn(String name, Class type, Object defaultValue) {
        // check lock status
        if ( m_locked ) {
            throw new IllegalStateException(
                "Can not add column to a locked Schema.");
        }
        // check for validity
        if ( name == null ) {
            throw new IllegalArgumentException(
                "Null column names are not allowed.");
        }
        if ( type == null ) {
            throw new IllegalArgumentException(
                "Null column types are not allowed.");
        }
        for ( int i=0; i<m_size; ++i ) {
            if ( m_names[i].equals(name) ) {
                throw new IllegalArgumentException(
                    "Duplicate column names are not allowed: "+m_names[i]);
            }
        }
        
        // resize if necessary
        // TODO put resizing functionality into library routines?
        if ( m_names.length == m_size ) {
            int capacity = (3*m_names.length)/2 + 1;
            String[] names = new String[capacity];
            Class[]  types = new Class[capacity];
            Object[] dflts = new Object[capacity];
            System.arraycopy(m_names, 0, names, 0, m_size);
            System.arraycopy(m_types, 0, types, 0, m_size);
            System.arraycopy(m_dflts, 0, dflts, 0, m_size);
            m_names = names;
            m_types = types;
            m_dflts = dflts;
        }
        
        m_names[m_size] = name;
        m_types[m_size] = type;
        m_dflts[m_size] = defaultValue;
        
        if ( m_lookup != null )
            m_lookup.put(name, new Integer(m_size));
        
        ++m_size;
    }
    
    /**
     * <p>Add a new interpolated column to this data schema. This actually adds
     * three columns to the schema: a column for the current value of the
     * field, and columns for starting and ending values. During animation or
     * operations spread over a time span, the current value can be
     * interpolated between the start and end values.</p>
     * 
     * <p>The name for the current value column is the name parameter provided
     * to the method. The name for the start and end columns will be determined
     * by the return value of {@link PrefuseLib#getStartField(String)} and
     * {@link PrefuseLib#getEndField(String)}. The default behavior for these
     * methods is to append ":start" to the name of a stating value column
     * and append ":end" to the the name of an ending value column.</p>
     * 
     * @param name the name of the interpolated column to add
     * @param type the data type the columns will contain
     * @param dflt the default value for each of the columns
     */
    public void addInterpolatedColumn(String name, Class type, Object dflt) {
        addColumn(name, type, dflt);
        addColumn(PrefuseLib.getStartField(name), type, dflt);
        addColumn(PrefuseLib.getEndField(name), type, dflt);
    }
    
    /**
     * Add an interpolated column with a null default value.
     * @see #addInterpolatedColumn(String, Class, Object)
     * @param name the name of the interpolated column to add
     * @param type the data type the columns will contain
     */
    public void addInterpolatedColumn(String name, Class type) {
        addInterpolatedColumn(name, type, null);
    }
    
    /**
     * Get the number of columns in this schema.
     * @return the number of columns
     */
    public int getColumnCount() {
        return m_size;
    }
    
    /**
     * The name of the column at the given position.
     * @param col the column index
     * @return the column name
     */
    public String getColumnName(int col) {
        return m_names[col];
    }
    
    /**
     * The column index for the column with the given name.
     * @param field the column name
     * @return the column index
     */
    public int getColumnIndex(String field) {
        if ( m_lookup == null )
            initLookup();
        
        Integer idx = (Integer)m_lookup.get(field);
        return ( idx==null ? -1 : idx.intValue() );
    }
    
    /**
     * The type of the column at the given position.
     * @param col the column index
     * @return the column type
     */
    public Class getColumnType(int col) {
        return m_types[col];
    }

    /**
     * The type of the column with the given name.
     * @param field the column name
     * @return the column type
     */
    public Class getColumnType(String field) {
        int idx = getColumnIndex(field);
        return ( idx<0 ? null : m_types[idx] );
    }
    
    /**
     * The default value of the column at the given position.
     * @param col the column index
     * @return the column's default value
     */
    public Object getDefault(int col) {
        return m_dflts[col];
    }
    
    /**
     * The default value of the column with the given name.
     * @param field the column name
     * @return the column's default value
     */
    public Object getDefault(String field) {
        int idx = getColumnIndex(field);
        return ( idx<0 ? null : m_dflts[idx] );
    }
    
    /**
     * Set the default value for the given field.
     * @param col the column index of the field to set the default for
     * @param val the new default value
     */
    public void setDefault(int col, Object val) {
        // check lock status
        if ( m_locked ) {
            throw new IllegalStateException(
                "Can not update default values of a locked Schema.");
        }
        m_dflts[col] = val;
    }
    
    /**
     * Set the default value for the given field.
     * @param field the name of column to set the default for
     * @param val the new default value
     */
    public void setDefault(String field, Object val) {
        // check lock status
        if ( m_locked ) {
            throw new IllegalStateException(
                "Can not update default values of a locked Schema.");
        }
        int idx = getColumnIndex(field);
        m_dflts[idx] = val;
    }
    
    /**
     * Set the default value for the given field as an int.
     * @param field the name of column to set the default for
     * @param val the new default value
     */
    public void setDefault(String field, int val) {
        setDefault(field, new Integer(val));
    }
    
    /**
     * Set the default value for the given field as a long.
     * @param field the name of column to set the default for
     * @param val the new default value
     */
    public void setDefault(String field, long val) {
        setDefault(field, new Long(val));
    }
    
    /**
     * Set the default value for the given field as a float.
     * @param field the name of column to set the default for
     * @param val the new default value
     */
    public void setDefault(String field, float val) {
        setDefault(field, new Float(val));
    }
    
    /**
     * Set the default value for the given field as a double.
     * @param field the name of column to set the default for
     * @param val the new default value
     */
    public void setDefault(String field, double val) {
        setDefault(field, new Double(val));
    }
    
    /**
     * Set the default value for the given field as a boolean.
     * @param field the name of column to set the default for
     * @param val the new default value
     */
    public void setDefault(String field, boolean val) {
        setDefault(field, val ? Boolean.TRUE : Boolean.FALSE);
    }
    
    /**
     * Set default values for the current, start, and end columns of an
     * interpolated column.
     * @param field the field name of the interpolated column
     * @param val the new default value for all three implicated columns
     */
    public void setInterpolatedDefault(String field, Object val) {
        setDefault(field, val);
        setDefault(PrefuseLib.getStartField(field), val);
        setDefault(PrefuseLib.getEndField(field), val);
    }
    
    /**
     * Set default values for the current, start, and end columns of an
     * interpolated column as an int.
     * @param field the field name of the interpolated column
     * @param val the new default value for all three implicated columns
     */
    public void setInterpolatedDefault(String field, int val) {
        setInterpolatedDefault(field, new Integer(val));
    }
    
    /**
     * Set default values for the current, start, and end columns of an
     * interpolated column as a long.
     * @param field the field name of the interpolated column
     * @param val the new default value for all three implicated columns
     */
    public void setInterpolatedDefault(String field, long val) {
        setInterpolatedDefault(field, new Long(val));
    }
    
    /**
     * Set default values for the current, start, and end columns of an
     * interpolated column as a float.
     * @param field the field name of the interpolated column
     * @param val the new default value for all three implicated columns
     */
    public void setInterpolatedDefault(String field, float val) {
        setInterpolatedDefault(field, new Float(val));
    }
    
    /**
     * Set default values for the current, start, and end columns of an
     * interpolated column as a double.
     * @param field the field name of the interpolated column
     * @param val the new default value for all three implicated columns
     */
    public void setInterpolatedDefault(String field, double val) {
        setInterpolatedDefault(field, new Double(val));
    }
    
    /**
     * Set default values for the current, start, and end columns of an
     * interpolated column as a boolean.
     * @param field the field name of the interpolated column
     * @param val the new default value for all three implicated columns
     */
    public void setInterpolatedDefault(String field, boolean val) {
        setInterpolatedDefault(field, val ? Boolean.TRUE : Boolean.FALSE);
    }
    
    
    // ------------------------------------------------------------------------
    // Comparison and Hashing
    
    /**
     * Compares this schema with another one for equality.
     */
    public boolean equals(Object o) {
        if ( !(o instanceof Schema) )
            return false;
        
        Schema s = (Schema)o;
        if ( m_size != s.getColumnCount() )
            return false;
        
        for ( int i=0; i<m_size; ++i ) {
            if ( !(m_names[i].equals(s.getColumnName(i)) &&
                   m_types[i].equals(s.getColumnType(i)) &&
                   m_dflts[i].equals(s.getDefault(i))) )
            {
                return false;
            }
        }
        return true;
    }
    
    /**
     * Indicates if values from a given Schema can be safely assigned to
     * data using this Schema. The input Schema must be less than or
     * equal in length to this Schema, and all contained columns in the
     * given Schema must have matching names and types to this Schema.
     * This method does not consider default values settings or the
     * locked status of the Schemas. For example, if the given Schema
     * has different default values than this one, this will have no
     * impact on the assignability of the two.
     * @param s the input Schema
     * @return true if data models using this Schema could be assigned values
     * directly from data using the input Schema, false otherwise.
     */
    public boolean isAssignableFrom(Schema s) {
        int ssize = s.getColumnCount();
        
        if ( ssize > m_size )
            return false;
        
        for ( int i=0; i<ssize; ++i ) {
            int idx = getColumnIndex(s.getColumnName(i));
            if ( idx < 0 )
                return false;
            
            if ( !m_types[idx].equals(s.getColumnType(i)) )
                return false;
        }
        return true;
    }
    
    /**
     * Computes a hashcode for this schema.
     */
    public int hashCode() {
        int hashcode = 0;
        for ( int i=0; i<m_size; ++i ) {
            int idx = i+1;
            int code = idx*m_names[i].hashCode();
            code ^= idx*m_types[i].hashCode();
            if ( m_dflts[i] != null )
                code ^= m_dflts[i].hashCode();
            hashcode ^= code;
        }
        return hashcode;
    }
    
    /**
     * Returns a descriptive String for this schema.
     */
    public String toString() {
        StringBuffer sbuf = new StringBuffer();
        sbuf.append("Schema[");
        for ( int i=0; i<m_size; ++i ) {
            if ( i > 0 ) sbuf.append(' ');
            sbuf.append('(').append(m_names[i]).append(", ");
            sbuf.append(m_types[i].getName()).append(", ");
            sbuf.append(m_dflts[i]).append(')');
        }
        sbuf.append(']');
        return sbuf.toString();
    }
    
    // ------------------------------------------------------------------------
    // Table Operations
       
    /**
     * Instantiate this schema as a new Table instance.
     * @return a new Table with this schema
     */
    public Table instantiate() {
        return instantiate(0);
    }
    
    /**
     * Instantiate this schema as a new Table instance.
     * @param nrows the number of starting rows in the table
     * @return a new Table with this schema
     */
    public Table instantiate(int nrows) {
        Table t = new Table(nrows, m_size);
        for ( int i=0; i<m_size; ++i ) {
            t.addColumn(m_names[i], m_types[i], m_dflts[i]);
        }
        return t;
    }
    
} // end of class Schema
