package org.opengroupware.jope.eoaccess;

import java.io.IOException;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.opengroupware.jope.eocontrol.EOFetchSpecification;
import org.opengroupware.jope.eocontrol.EOQualifier;
import org.opengroupware.jope.eocontrol.EOSortOrdering;
import org.opengroupware.jope.foundation.NSJavaRuntime;
import org.opengroupware.jope.foundation.NSObject;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

public class EOModelLoader extends NSObject {
  protected static final Log log = LogFactory.getLog("EOModel");

  /* statics */

  protected static DocumentBuilderFactory dbf;
  static {
    dbf = DocumentBuilderFactory.newInstance();
    dbf.setNamespaceAware(true);
    dbf.setCoalescing(true); /* join adjacent texts */
    dbf.setIgnoringComments(true);
  }
  
  /* ivars */
  
  protected Exception lastException = null;
  
  /* accessors */
  
  public Exception lastException() {
    return this.lastException;
  }
  
  public void clear() {
    this.lastException = null;
  }
  
  /* loading the model */
  
  protected EOModel loadModelFromElement(Element _node) {
    if (_node == null) {
      this.addError("got no model node for parsing");
      return null;
    }
    if (!_node.getTagName().equals("model")) {
      this.addError("given node is not a <model> tag");
      return null;
    }
    
    EOEntity[] entities = null;
    
    NodeList entityNodes = _node.getElementsByTagName("entity");
    if (entityNodes != null && entityNodes.getLength() > 0) {
      int entityCount = entityNodes.getLength();
      entities = new EOEntity[entityCount];
      for (int i = 0; i < entityCount; i++) {
        entities[i] = this.loadEntityFromElement((Element)entityNodes.item(i));
        if (entities[i] == null) {
          this.addError("could not load entity from node: " +
                        entityNodes.item(i));
          return null;
        }
      }
    }
    else {
      /* No entities are specified in the model, which means that we should
       * fetch all data from the information schema of the database. This is
       * the same like this model:
       *   <model version="1.0">
       *     <entity tableNameLike="*">
       *       <attribute columnNameLike="*" />
       *     </entity>
       *   </model>
       */
      
      EOAttribute[] patternAttributes = new EOAttribute[1];
      patternAttributes[0] =
        EOAttribute.patternAttributeForColumnWithNameLike("*");
      if (patternAttributes[0] == null) {
        this.addError("could not construct a-pattern");
        return null;
      }
      
      entities = new EOEntity[1];
      entities[0] =
        EOEntity.patternEntityForExternalNameLike("*", patternAttributes);
      if (entities[0] == null) {
        this.addError("could not construct e-pattern");
        return null;
      }
    }
    
    return new EOModel(entities);
  }
  protected EOModel loadModelFromDocument(Document _doc) {
    return this.loadModelFromElement(_doc.getDocumentElement());
  }
  
  protected EOEntity loadEntityFromElement(Element _node) {
    if (_node == null)
      return null;
    if (!_node.getTagName().equals("entity")) {
      log.info("given node is not a <entity> tag");
      return null;
    }
    
    EOAttribute[] attrs = this.loadAttributesFromElement(_node);
    if (attrs == null)
      return null;
    
    /* determine attributes */

    String tableName   = _node.getAttribute("table");
    String entityName  = _node.getAttribute("name");
    String className   = _node.getAttribute("class");
    String dsClassName = _node.getAttribute("datasource");
    
    boolean tableNameIsPattern = false;
    if (tableName == null || tableName.length() == 0) {
      tableNameIsPattern = true;
      tableName = _node.getAttribute("tableNameLike");
      if (tableName == null || tableName.length() == 0) {
        if (entityName != null && entityName.length() > 0) {
          /* be tolerant, derive tablename from entity name ... */
          tableNameIsPattern = false;
          tableName = entityName;
        }
        else {
          this.addError("missing table name for entity");
          return null;
        }
      }
    }
    
    if (entityName == null || entityName.length() == 0) {
      if (!tableNameIsPattern)
        entityName = tableName;
    }
    
    if (className != null && className.length() == 0)
      className = null;
    if (dsClassName != null && dsClassName.length() == 0)
      dsClassName = null;
    
    String pkeys[] = null;
    String pkeyv = _node.getAttribute("primarykey");
    if (pkeyv != null && pkeyv.length() > 0) {
      pkeys = new String[] { pkeyv };
    }
    else {
      /* treat the value as a CSV list */
      pkeyv = _node.getAttribute("primarykeys");
      if (pkeyv != null && pkeyv.length() > 0)
        pkeys = pkeyv.split(",");
    }
    
    /* load fetch specifications and adaptor ops */
    
    Map<String, EOFetchSpecification> fspecs =
      this.loadFetchSpecificationsFromElement(entityName, _node); 

    Map<String, EOAdaptorOperation[]> ops =
      this.loadAdaptorOperationsFromElement(entityName, _node); 
    
    /* construct */
    
    EOEntity entity =
      new EOEntity(entityName, tableName, tableNameIsPattern,
                   className, dsClassName,
                   attrs, pkeys, fspecs, ops);
    return entity;
  }
  
  /* attributes */
  
  protected EOAttribute[] loadAttributesFromElement(Element _node) {
    EOAttribute[] attrs = null;
    NodeList attrNodes = _node.getElementsByTagName("attribute");
    if (attrNodes != null && attrNodes.getLength() > 0) {
      int attrCount = attrNodes.getLength();
      attrs = new EOAttribute[attrCount];
      for (int i = 0; i < attrCount; i++) {
        attrs[i] = this.loadAttributeFromElement((Element)attrNodes.item(i));
        if (attrs[i] == null) {
          log.info("failed to load an attribute node of the model: " +
                        attrNodes.item(i));
          return null;
        }
      }
    }
    else {
      /* No attributes are specified in the entity, which means that we should
       * fetch the data from the information schema of the database. This is
       * the same like specifying this <entity> child tag:
       *   <attribute columnNameLike="*" />
       */
      attrs = new EOAttribute[1];
      attrs[0] = EOAttribute.patternAttributeForColumnWithNameLike("*");
    }
    return attrs;
  }
  
  protected EOAttribute loadAttributeFromElement(Element _node) {
    /*
     * Eg:
     *   <attribute column="id" autoincrement="true" notnull="true" />
     */
    
    if (_node == null)
      return null;
    if (!_node.getTagName().equals("attribute")) {
      log.info("given node is not a <entity> tag");
      return null;
    }
    
    /* extract attributes */
    
    String s;
    
    boolean isColumnPattern = false;
    
    s = _node.getAttribute("name");
    String name   = s != null && s.length() > 0 ? s : null;
    s = _node.getAttribute("column");
    String column = s != null && s.length() > 0 ? s : null;
    
    if (column == null) {
      s = _node.getAttribute("columnNameLike");
      column = s != null && s.length() > 0 ? s : null;
      if (column == null) {
        if ((column = name) == null) {
          this.addError("missing column name for attribute: "+ _node);
          return null;          
        }
      }
      else
        isColumnPattern = true;
    }
    else if (name == null)
      name = column;
    
    s = _node.getAttribute("autoincrement");
    Boolean isAutoIncrement = s != null 
      ? new Boolean(NSJavaRuntime.boolValueForObject(s))
      : null;
    s = _node.getAttribute("notnull");
    Boolean isNotNull = s != null 
      ? new Boolean(NSJavaRuntime.boolValueForObject(s))
      : null;
    
    // TODO: column type
      
    EOAttribute attr = new EOAttribute(name, column, isColumnPattern,
                                       _node.getAttribute("type"),
                                       isAutoIncrement, isNotNull,
                                       null, // TODO: width
                                       null, // TODO: default
                                       null, /* comment     */
                                       null, /* collation   */
                                       null  /* privileges */);
    return attr;
  }
  
  protected EORelationship[] loadRelationshipsFromElement(Element _node) {
    EORelationship[] objects = null;
    NodeList nodes1 = _node.getElementsByTagName("to-one");
    NodeList nodesN = _node.getElementsByTagName("to-many");

    if ((nodes1 == null || nodes1.getLength() == 0) &&
        (nodesN == null || nodesN.getLength() == 0))
      return null;
    
    objects = new EORelationship[nodes1.getLength() + nodesN.getLength()];
    
    int j = 0;

    for (int i = 0; i < nodes1.getLength(); i++, j++) {
      objects[j] = this.loadRelationshipFromElement((Element)nodes1.item(i));
      if (objects[j] == null) {
        log.info("failed to load an relationship node of the model: " +
                      nodes1.item(i));
        return null;
      }
    }

    for (int i = 0; i < nodesN.getLength(); i++, j++) {
      objects[j] = this.loadRelationshipFromElement((Element)nodesN.item(i));
      if (objects[j] == null) {
        log.info("failed to load an relationship node of the model: " +
                      nodesN.item(i));
        return null;
      }
    }
    return objects;
  }
  
  protected EORelationship loadRelationshipFromElement(Element _node) {
    /*
     * Eg:
     *   <to-one  name="toProject" to="Project" join="companyId,ownerId" />
     *   <to-many name="toOwner"   to="Account" join="ownerId,companyId" />
     * 
     * TODO: support join subelements
     */
    
    if (_node == null)
      return null;
    if (!_node.getTagName().startsWith("to")) {
      log.info("given node is not a <relationship> tag");
      return null;
    }
    
    /* extract attributes */
    
    String s;
    
    boolean isToMany = _node.getTagName().contains("many");
    
    s = _node.getAttribute("name");
    String name   = s != null && s.length() > 0 ? s : null;

    s = _node.getAttribute("to");
    String destEntity = s != null && s.length() > 0 ? s : null;
    if (destEntity == null) {
      s = _node.getAttribute("destination");
      destEntity = s != null && s.length() > 0 ? s : null;
    }
    if (destEntity == null) {
      s = _node.getAttribute("target");
      destEntity = s != null && s.length() > 0 ? s : null;
    }
    
    /* join attribute */
    
    s = _node.getAttribute("join");
    String[] parts = s != null ? s.split(",") : null;
    EOJoin[] joins = null;
    
    if (parts != null && parts.length == 1) {
      joins = new EOJoin[1];
      joins[0] = new EOJoin(parts[0], parts[0]);
    }
    else if (parts != null && parts.length > 1) {
      joins = new EOJoin[parts.length / 2];
      for (int i = 0; i < parts.length; i += 2)
        joins[i / 2] = new EOJoin(parts[i], parts[i + 1]);
    }
    // TODO: join subelements
    
    /* construct */
      
    EORelationship rel = new EORelationship
      (name, isToMany, null /* entity */, destEntity, joins);
    return rel;
  }

  
  /* fetch specifications */
  
  protected Map<String, EOFetchSpecification>
    loadFetchSpecificationsFromElement(String _entityName, Element _node)
  {
    NodeList fetchNodes = _node.getElementsByTagName("fetch");
    if (fetchNodes == null || fetchNodes.getLength() == 0)
      return null;
    
    int count = fetchNodes.getLength();
    Map<String, EOFetchSpecification> fspecs = 
      new HashMap<String, EOFetchSpecification>(count);

    for (int i = 0; i < count; i++) {
      Element node = (Element)fetchNodes.item(i);
      
      EOFetchSpecification fs = 
        this.loadFetchSpecificationFromElement(_entityName, node);
      if (fs == null) {
        log.info("failed to load a fetch node of the model: " + node);
        continue;
      }
      
      String fsname = node.getAttribute("name");
      if (fsname == null || fsname.length() == 0) {
        log.info("missing name in a fetch node of the model: " + node);
        continue;
      }
      if (fspecs.containsKey(fsname)) {
        log.info("duplicate name in a fetch node of the model: " + node);
        continue;
      }
      
      fspecs.put(fsname, fs);
    }
    
    return fspecs;
  }
  
  protected EOFetchSpecification loadFetchSpecificationFromElement
    (String _entityName, Element _node)
  {
    /*
     * Eg:
     *   <fetch name="count" rawrows="true">
     *     <sql>SELECT COUNT(*) FROM table</sql>
     *   </fetch>
     *   
     *   <fetch name="abc" distinct="true" deep="false" lock="false"
     *          requiresAllBindings="true" limit="100" offset="0"
     *          entity="Contact"
     *          attributes="id,lastname,firstname"
     *     >
     *     <qualifier>lastname like $name</qualifier>
     *     <ordering key="balance" order="DESC" />
     *     <ordering key="lastname" />
     *   </fetch>
     *   
     * TODO: would be cool to allow intermixing of raw SQL and qualifiers, like:
     *   <fetch name="count" rawrows="true">
     *     <sql>SELECT COUNT(T.*) FROM table T, rel X WHERE </sql>
     *     <qualifier>T.lastname = $lastname</qualifier>
     *     <sql> AND T.id = X.id</sql>
     *     <ordering>lastname</ordering>
     *   </fetch>
     */
    
    if (_node == null)
      return null;
    if (!_node.getTagName().equals("fetch")) {
      log.info("given node is not a <fetch> tag");
      return null;
    }
    
    Map<String, Object> hints = new HashMap<String, Object>(4);
    String s;
    
    /* flags */
    
    Boolean fetchesRawRows = this.getBoolAttribute(_node, "rawrows");
    Boolean distinct       = this.getBoolAttribute(_node, "distinct");
    Boolean deep           = this.getBoolAttribute(_node, "deep");
    Boolean lock           = this.getBoolAttribute(_node, "lock");
    Boolean requiresAllBindings = 
      this.getBoolAttribute(_node, "requiresAllBindings");
    
    if (distinct == null) distinct = Boolean.FALSE;
    if (deep     == null) deep     = Boolean.FALSE;
    
    /* more attributes */
      
    Integer limit  = this.getIntAttribute(_node, "limit");
    Integer offset = this.getIntAttribute(_node, "offset");
    String  entityName = _node.getAttribute("entity");
    if (entityName != null && entityName.length() == 0)
      entityName = null;
    if (entityName == null)
      entityName = _entityName;
    
//    /* walk elements */
//    TBD: walk objects in sequence, possibly creating merged SQL expressions
//    
//    NodeList children = _node.getChildNodes();
//    if (children != null && children.getLength() > 0) {
//      List<String> collectAttrs = new ArrayList<String>(8);
//      
//      for (int i = 0; i < children.getLength(); i++) {
//        Node node = children.item(i);
//        if (node == null || !(node instanceof Element)) continue;
//        
//        Element element = (Element)node;
//        String  tagName = element.getTagName();
//        
//        if ("attributes".equals(tagName)) {
//        }
//      }
//    }
    
    /* fetch attributes */
    
    String[] fetchAttributes = null;
    
    s = _node.getAttribute("attributes");
    if (s != null && s.length() > 0) {
      fetchAttributes = s.split(",");
    }
    else {
      s = this.joinTrimmedTextsOfElements
        (_node.getElementsByTagName("attributes"),",");
      if (s != null && s.length() > 0)
        fetchAttributes = s.split(",");
    }
    
    /* qualifiers */
    
    s = this.joinTrimmedTextsOfElements
      (_node.getElementsByTagName("qualifier")," AND ");
    EOQualifier qualifier = s != null
      ? EOQualifier.qualifierWithQualifierFormat(s)
      : null;
    if (s != null && qualifier == null) {
      log.error("could not parse qualifier in model:\n'" + s + "'");
      return null;
    }
    
    /* sort-orderings */
    
    EOSortOrdering[] orderings = null;
    NodeList orderingNodes = _node.getElementsByTagName("ordering");
    if (orderingNodes != null && orderingNodes.getLength() > 0) {
      orderings = new EOSortOrdering[orderingNodes.getLength()];
      for (int i = 0; i < orderingNodes.getLength(); i++) {
        orderings[i] =
          this.loadOrderingFromElement((Element)orderingNodes.item(i));
        if (orderings[i] == null) {
          log.error("could not parse an ordering in model: " + 
                    orderingNodes.item(i));
          return null;
        }
      }
    }
    
    /* custom SQL */
    
    NodeList sqlNodes = _node.getElementsByTagName("sql");
    s = this.joinTrimmedTextsOfElements(sqlNodes, "; ");
    if (s != null && s.length() > 0) {
      Boolean pat = this.getBoolAttribute((Element)sqlNodes.item(0), "pattern");
      if (pat != null && pat)
        hints.put("EOCustomQueryExpressionHintKeyBindPattern", s);
      else
        hints.put("EOCustomQueryExpressionHintKey", s);
    }
    
    /* construct */
    
    if (hints.size() == 0) hints = null;
    
    EOFetchSpecification fs = new EOFetchSpecification
      (entityName, qualifier, orderings, distinct, deep, hints);
    
    if (fetchesRawRows  != null) fs.setFetchesRawRows(fetchesRawRows);
    if (lock            != null) fs.setLocksObjects(lock);
    if (limit           != null) fs.setFetchLimit(limit);
    if (offset          != null) fs.setFetchOffset(offset);
    if (fetchAttributes != null) fs.setFetchAttributeNames(fetchAttributes);
    
    if (requiresAllBindings != null)
      fs.setRequiresAllQualifierBindingVariables(requiresAllBindings);
    
    return fs;
  }
  
  protected EOSortOrdering loadOrderingFromElement(Element _e) {
    /*
     * Eg:
     *   <ordering key="lastname" operation="DESC" />
     */
    if (_e == null)
      return null;
    if (!_e.getTagName().startsWith("order")) { // allow orderby, etc
      log.info("given node is not a <ordering> tag");
      return null;
    }
    
    /* determine key */
    
    String key = _e.getAttribute("key");
    if (key == null || key.length() == 0)
      key = _e.getTextContent();
    
    if (key == null || key.length() == 0) {
      log.error("could not parse an ordering in model: " + _e); 
      return null;
    }
    
    /* determine selector */
    
    String ssel = _e.getAttribute("operation");
    if (ssel == null || ssel.length() == 0) ssel = _e.getAttribute("op");
    if (ssel == null || ssel.length() == 0) ssel = _e.getAttribute("order");
    Object sel;
    if (ssel == null || ssel.length() == 0)
      sel = EOSortOrdering.EOCompareAscending;
    else if ("ASC".equalsIgnoreCase(ssel))
      sel = EOSortOrdering.EOCompareAscending;
    else if ("DESC".equalsIgnoreCase(ssel))
      sel = EOSortOrdering.EOCompareDescending;
    else if ("CASE ASC".equalsIgnoreCase(ssel))
      sel = EOSortOrdering.EOCompareCaseInsensitiveAscending;
    else if ("CASE DESC".equalsIgnoreCase(ssel))
      sel = EOSortOrdering.EOCompareCaseInsensitiveDescending;
    else
      sel = ssel;
    
    return new EOSortOrdering(key, sel);
  }
  
  
  /* adaptor operations */
  
  protected Map<String, EOAdaptorOperation[]>
    loadAdaptorOperationsFromElement(String _entityName, Element _node)
  {
    NodeList opNodes = _node.getElementsByTagName("operation");
    if (opNodes == null || opNodes.getLength() == 0)
      return null;
    
    int count = opNodes.getLength();
    Map<String, EOAdaptorOperation[]> ops = 
      new HashMap<String, EOAdaptorOperation[]>(count);

    for (int i = 0; i < count; i++) {
      Element node = (Element)opNodes.item(i);
      
      EOAdaptorOperation[] op = 
        this.loadAdaptorOperationFromElement(_entityName, node);
      if (op == null) {
        log.info("failed to load an operation node of the model: " + node);
        continue;
      }
      
      String opname = node.getAttribute("name");
      if (opname == null || opname.length() == 0) {
        log.info("missing name in an operation of the model: " + node);
        continue;
      }
      if (ops.containsKey(opname)) {
        log.info("duplicate name in an operation of the model: " + node);
        continue;
      }
      
      ops.put(opname, op);
    }
    
    return ops;
  }
    
  protected EOAdaptorOperation[] loadAdaptorOperationFromElement
    (String _entityName, Element _node)
  {
    log.error("loading of operations not yet implemented ...");
    return null;
  }
  
  /* support */
  
  protected String joinTrimmedTextsOfElements(NodeList _nodes, String _sep) {
    if (_nodes == null || _nodes.getLength() == 0)
      return null;
    
    StringBuffer sb = new StringBuffer(256);
    boolean isFirst = true;
    for (int i = 0; i < _nodes.getLength(); i++) {
      Element node = (Element)_nodes.item(i);
      node.normalize();
      
      String txt = node.getTextContent();
      if (txt == null) continue;
      txt = txt.trim();
      if (txt.length() == 0) continue;
      
      if (isFirst)
        isFirst = false;
      else if (_sep != null)
        sb.append(_sep);
      
      sb.append(txt);
    }
    return sb.length() > 0 ? sb.toString() : null;
  }
  
  /* attribute helpers */
  
  protected Boolean getBoolAttribute(Element _element, String _attrName) {
    if (_element == null || _attrName == null)
      return null;
    
    String s = _element.getAttribute(_attrName);
    if (s == null || s.length() == 0)
      return null;
    
    return NSJavaRuntime.boolValueForObject(s);
  }
  
  protected Integer getIntAttribute(Element _element, String _attrName) {
    if (_element == null || _attrName == null)
      return null;
    
    String s = _element.getAttribute(_attrName);
    if (s == null || s.length() == 0)
      return null;

    return Integer.parseInt(s);
  }
  
  /* support */
  
  protected Exception newModelLoadingException(String _reason) {
    // TODO: improve error handling
    return new Exception(_reason);
  }
  
  protected void addError(String _reason) {
    log.error(_reason);
    this.lastException = this.newModelLoadingException(_reason);
  }
  protected void addError(String _reason, Exception _e) {
    log.error(_reason, _e);
    
    // TODO: wrap exception
    this.lastException = _e;
  }
  
  public EOModel loadModelFromURL(URL _url) {
    boolean isDebugOn = log.isDebugEnabled();
    if (isDebugOn) log.debug("loading model from URL: " + _url);
    
    if (_url == null) {
      this.addError("missing URL parameter for loading model");
      return null;
    }
    
    /* instantiate document builder */
    
    DocumentBuilder db;
    try {
       db = dbf.newDocumentBuilder();
       if (isDebugOn) log.debug("  using DOM document builder:" + db);
    }
    catch (ParserConfigurationException e) {
      this.addError("failed to create docbuilder for parsing URL: " + _url, e);
      return null;
    }
    
    /* load DOM */
    
    Document doc;
    try {
      doc = db.parse(_url.openStream(), _url.toString());
      if (isDebugOn) log.debug("  parsed DOM: " + doc);
    }
    catch (SAXException e) {
      this.addError("XML error when loading model resource: " + _url, e);
      return null;
    }
    catch (IOException e) {
      this.addError("IO error when loading model resource: " + _url, e);
      return null;
    }
    
    /* transform DOM into model */

    EOModel model = this.loadModelFromDocument(doc);
    
    if (isDebugOn && model != null) {
      log.debug("  model: " + model);
      log.debug("finished model from URL: " + _url);
    }
    if (model == null)
      log.info("failed loading model from URL: " + _url);
    
    return model;
  }
}
