// $Id$
/*
 * Copyright 2010 Institute for Systems Biology
 *                Seattle, Washington, USA.
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package biotextEngine.xmlparsers;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Stack;
import java.util.logging.Level;
import java.util.logging.Logger;

import biotextEngine.util.Scrivener;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

/**
 * This is a superclass for all the classes that parse the XML from a given node
 * to populate the tables in the relational database
 * 
 * @author Gaurav Bhalotia
 * @author Ariel Schwartz
 */
public class NodeHandler extends DefaultHandler {
    private static final Logger LOGGER = Scrivener.getLogger(NodeHandler.class);

    protected PreparedStatement pstmt = null;
    protected static final Map<String, PreparedStatement> preparedStatements = new HashMap<String, PreparedStatement>();
    protected int numColumns;
    protected String tableName = null;
    protected String xmlNodeName;

    protected boolean updateChildren = true;

    /* Names of the columns to be stored in the database */
    protected String[] columnName;

    /*
     * Corresponding XML element names for each of the columns If element name
     * is DATE and the type is Types.DATE then it is composed of Year, Month and
     * Day
     */
    protected String[] xmlElementName;

    /* Corresponding the type for each of the columns */
    protected int[] columnType;

    /*
     * The hashtable to store the values for each element obtained from the XML
     * file
     */
    protected Map<String, String> columnValues = null;

    /* Hash Set to store column names for validating the parser */
    protected Set<String> columnNames = null;

    /* The various columns needed for the record */
    /*
     * To store the current element and value in the parse stream from the
     * GenericXMLParser
     */
    protected String currentElement = null;
    protected String currentValue = null;
    static protected Stack<String> elements = new Stack<String>();

    /* The ContentHandlers of the children nodes */
    protected List<NodeHandler> childrenHandlers = new ArrayList<NodeHandler>();

    /* The ContentHandlers of the parent nodes, e.g. Journal for Article */
    protected ArrayList<NodeHandler> parentHandlers = new ArrayList<NodeHandler>();

    /*
     * The list of Columns Values that need to be returned from the
     * corresponding parent handlers to this node, e.g. Journal returns ISSN to
     * MedlineCitation
     */
    protected ArrayList<String> parentHandlerReturnColumns = new ArrayList<String>();

    protected String tagPrefix = "";
    protected boolean ignoreDuplicateKeyError = false;

    // FIXME coded to DB2, not MySQL
    protected static final int DB2_DUPLICATE_ERROR = -803;

    /**
     * The constructor takes in the SAX event handler. Which is used to parse
     * the elements in the authorlist subnode.
     */
    public void initialize() throws Exception {

        /* Initialize the number of columns */
        numColumns = columnName.length;

        /* Check if the statement is null then compile a statement */
        if (pstmt == null) {
            /* Now prepare the statement to be used for updating DB */
            compileStatement();
        }

        /* Initialize the hash table */
        columnValues = new HashMap<String, String>();

        /*
         * If parse validate option is on initialize the set object to hold the
         * columnNames
         */
        columnNames = new HashSet<String>();
    }

    /**
     * Compile a statement, later on while updating the DB we just need to
     * supply the arguments
     */
    private void compileStatement() throws Exception {

        try {
            pstmt = preparedStatements.get(tableName);
            if (pstmt != null) {
                return;
            }
            
            Connection con = GenericXMLParser.getDbConnection();
            
            /* Create the parameter string */
            if (numColumns < 1) {
                throw new Exception("This table does not have any columns");
                
            } else {
                
                StringBuilder params = new StringBuilder(this.columnName.length * 2);
                StringBuilder cols = new StringBuilder(this.columnName.length * 20);
                
                // build param and column name list
                for (String columnName : this.columnName) {
                    params.append("?,");
                    cols.append(columnName).append(',');
                }
                
                // chop off the trailing comma
                params.deleteCharAt(params.length() - 1);
                cols.deleteCharAt(cols.length() - 1);
                
                // construct into complete insert statement
                String sql = String.format(
                        "INSERT INTO %s (%s) VALUES (%s)",
                        this.tableName,
                        cols.toString(),
                        params.toString()
                        );
                
                this.pstmt = con.prepareStatement( sql );

                preparedStatements.put(tableName, pstmt);
            }
            
        } catch (SQLException ex) {
            LOGGER.log(Level.SEVERE, ex.getMessage(), ex);
            throw new Exception("Problems with the connection to the database");
        }
    }

    /**
     * Function to handle the event where an element begins corresponding to the
     * author stream
     * 
     * @param namespaceURI The namespace information for this element
     * @param localName The actual name of the element
     * @param qName Combination of the Namespace alias and the localName
     * @param atts Any attributes for the element
     */
    public void startElement(String namespaceURI, String localName,
            String qName, Attributes atts) throws SAXException {

        /*
         * Assuming well formed XML, previous element ends before a new one
         * starts
         */
        currentElement = qName;
        elements.push(qName);
        tagPrefix += qName + ".";

        /*
         * Add an empty string for this element to the hashtable. to be later
         * filled in the character() method
         */
        currentValue = "";

        /* Add the attributes with their values to the hashtable */
        for (int att = 0; att < atts.getLength(); att++) {
            String attName = atts.getQName(att);
            /* Prepend the atribute names by the element name */
            putColumnValue(tagPrefix + attName, atts.getValue(att));
        }

    }

    /**
     * Function to handle the end element event from the GenericXMLParser
     * 
     * @param namespaceURI
     * @param localName
     * @param qName
     */
    public void endElement(String namespaceURI, String localName, String qName)
            throws SAXException {

        /*
         * Assuming here that the XML is well formed and the endElement are in
         * correct order
         */
        elements.pop();
        if (qName.equals(currentElement)) {

            tagPrefix = tagPrefix.substring(0, tagPrefix.length()
                    - currentElement.length() - 1);

            if (currentValue != null) {

                /*
                 * Prefix the TAG with the ancestors (using the stack), e.g.
                 * DateCreated.Year
                 */
                /* For now just appending with the parents */
                putColumnValue(tagPrefix + currentElement, currentValue);
            }
            try {
                currentElement = (String) elements.peek();
                currentValue = null;
            } catch (Exception e) {
                throw new SAXException("Empty stack. currentElement: "
                        + currentElement + " qName: " + qName);
            }
            if (tagPrefix.equals("")) {
                /* This just means the end of the Table type, e.g Author */
                GenericXMLParser.removeChildHandler();
            }
        } else if (currentElement != null) {
            LOGGER.severe("currentElement=null,qName=" + qName 
                    + ",elements=" + elements);
            throw new SAXException("misformed XML currentElement: "
                    + currentElement + " qName: " + qName);
        }
    }

    /**
     * Function to handle the characters that have been passed to this object
     * from the main GenericXMLParser; The element these characters belong to
     * has been set by the previous startElement event
     * 
     * @param ch The character array containing the characters
     * @param start The position where the characters corresponding to this
     *        element start
     * @param length The length of the character string for the current element
     */
    public void characters(char[] ch, int start, int length)
            throws SAXException {

        /*
         * I assume that the XML is well formed, the characters coming now
         * should correspond to the current element
         */
        if (currentElement == null) {
            throw new SAXException("misformed XML");
        } else {
            currentValue += new String(ch, start, length);
        }

    }

    /**
     * Function to materialize the elements in this object to the database This
     * method has to be implemented by the extending class
     */
    public void updateDB() throws Exception {

        String retColumnName = null;
        String retColumnValue = null;

        /* Execute updates for parent nodes */
        for (int ii = 0; ii < parentHandlers.size(); ii++) {
            retColumnName = parentHandlerReturnColumns.get(ii);
            if (retColumnName.equals("")) {
                /* Do nothing */
            } else {
                try {
                    /* Get the column value and put in this current node */
                    retColumnValue = parentHandlers.get(ii).getColumnValue(retColumnName);
                    putColumnValue(retColumnName, retColumnValue);
                    
                } catch (NullPointerException npe) {
                    LOGGER.log(Level.SEVERE, "name=" + retColumnName 
                            + ",value=" + retColumnValue 
                            + ",map=" + this.columnValues, npe);
                    System.exit(1);
                }
            }
            /* Update update for the parent */
            parentHandlers.get(ii).updateDB();
        }

        try {
            Object tempVal;

            for (int i = 0; i < numColumns; i++) {
                try {
                    if (columnType[i] == Types.DATE) {
                        /* handle this separately */

                        String date = getColumnValue(xmlElementName[i]
                                + ".Year");
                        if (date == null) {
                            pstmt.setNull(i + 1, Types.DATE);
                        } else {
                            date += "-"
                                    + getColumnValue(xmlElementName[i]
                                            + ".Month")
                                    + "-"
                                    + getColumnValue(xmlElementName[i] + ".Day");

                            /* Now create a date type from this */
                            pstmt.setString(i + 1, date); /*
                                                           * DB converts from
                                                           * string to DATE
                                                           */
                        }

                    } else {
                        tempVal = getColumnValue(xmlElementName[i]);

                        if (tempVal == null) {
                            /* Set the parameter to be null */
                            pstmt.setNull(i + 1, columnType[i]);
                        } else {
                            /* Set the parameter value with appropriate type */
                            pstmt.setObject(i + 1, tempVal, columnType[i]);
                        }
                    }
                } catch (ArrayIndexOutOfBoundsException e) {
                    throw new Exception("Problem updating table " + tableName
                            + " i: " + i + " columnType.length: "
                            + columnType.length);
                }
            }

            /*
             * If parse validation is ON check if all the values in the
             * hashtable have been used
             */
            if (GenericXMLParser.parseValidate == true) {

                if (columnNames.isEmpty()) {
                    /* Parse is good, all values are being used */
                } else {
                    /* There are some values that are not being used */
                    LOGGER.warning("Some unused columns for " + this 
                            + ": " + this.columnNames);
                }

            }
        }

        catch (SQLException ex) {
            LOGGER.log(Level.SEVERE, ex.getMessage(), ex);
            System.exit(-1);
            // throw new Exception("Problems with the prepared statement");
        }

        /* Now execute the update with the database */
        try {
            if (GenericXMLParser.IS_TO_FILE == false) {
                pstmt.executeUpdate();
            } else {
                /* Write it to a file (Opened already) */
                
                /*
                 * XXX Specific to MySQL Connector/J
                 * JDBC doesn't support getting the formatted SQL statement
                 * from the PreparedStatement.  The .toString() of the MySQL
                 * driver happens to contain it, after the class identifier.
                 * Ergo, this is pretty sloppy, but really not much more than
                 * what else you see here.
                 */
                
                // get the output from the statement which should look like:
                // com.mysql.jdbc.JDBC4PreparedStatement@1ed0af9b: INSERT INTO medline_[...]
                String sql = this.pstmt.toString();
                
                // look for the start of the actual insert statement
                int insertIdx = sql.indexOf("INSERT");
                
                // hope desperately this never happens
                if (-1 == insertIdx) {
                    insertIdx = 0;
                    
                    LOGGER.warning("Didn't find 'INSERT' in this statement: " 
                            + sql);
                }
                
                // save out the sql data
                GenericXMLParser.OUTFILE.print(sql.substring(insertIdx));
                GenericXMLParser.OUTFILE.println(';');  // requires ; terminal
            }

        } catch (SQLException ex) {
            if (!handleSQLException(ex)) {
                LOGGER.log(Level.SEVERE, "Exception with: " + this.pstmt, ex);
                throw new Exception("Problem in inserting values into the "
                        + tableName + " table");
            }
        }

        /* Execute updates for children nodes */
        if (updateChildren == true) {
            for (NodeHandler handler : this.childrenHandlers) {
                handler.updateDB();
            }
        }
    }

    /**
     * Handles SQLException. Should be overloaded by inheriting classes to
     * handle special cases
     * 
     * @returns true if the exception has been handled, false otherwise
     */
    protected boolean handleSQLException(SQLException ex) {
        // FIXME DB2 dependent; MySQL equivalence?
        if (ignoreDuplicateKeyError && ex.getErrorCode() == DB2_DUPLICATE_ERROR) {
            updateChildren = false;
            return true;
            /*
             * Don't do anything, the tuple for this primary key has already
             * been inserted
             */
        } else {
            LOGGER.log(Level.SEVERE, "ERROR CODE: " + ex.getErrorCode(), ex);
            return false;
        }
    }

    /**
     * Normal Content Handler where the handler being set has to be serialized
     * to the DB after this node
     */
    public void setContentHandler(NodeHandler childHandler,
            String namespaceURI, String localName, String qName, Attributes atts)
            throws SAXException {
        if (this != childHandler) {
            childrenHandlers.add(childHandler);
        }
        GenericXMLParser.addChildHandler(childHandler);
        childHandler.startElement(namespaceURI, localName, qName, atts);
        currentValue = null;
    }

    /**
     * Content handler for a node that needs to be serialized to the DB before
     * this node
     * 
     * @param returnColumn Stores the column name that needs to be returned by
     *        the parent should be "" if no return is desired
     */
    public void setContentHandlerParent(String returnColumn,
            NodeHandler childHandler, String namespaceURI, String localName,
            String qName, Attributes atts) throws SAXException {
        if (this != childHandler) {
            parentHandlers.add(childHandler);
            parentHandlerReturnColumns.add(returnColumn);
        }
        GenericXMLParser.addChildHandler(childHandler);
        childHandler.startElement(namespaceURI, localName, qName, atts);
    }

    /*
     * Return the value of a column given its name, removing it from the
     * hashtable
     */
    protected String getColumnValue(String columnName) {

        if (GenericXMLParser.parseValidate == true) {
            /* remove the name from the hashset, as it has been used */
            columnNames.remove(columnName);
        }

        return (String) columnValues.get(columnName);
    }

    /* Store the value of a column given its name */
    protected void putColumnValue(String columnName, String columnValue) {
        columnValues.put(columnName, columnValue);

        if (GenericXMLParser.parseValidate == true) {
            /* Store the column name in the set */
            columnNames.add(columnName);
        }
    }

    /**
     * Adds an element to the element stack
     * 
     * @param qName the element to be added
     */
    public static void pushElement(String qName) {
        elements.push(qName);
    }

}
