/* Copyright (C) 2004-2008  Rajarshi Guha <rajarshi.guha@gmail.com>
 *
 *  Contact: cdk-devel@lists.sourceforge.net
 *
 *  This program is free software; you can redistribute it and/or
 *  modify it under the terms of the GNU Lesser General Public License
 *  as published by the Free Software Foundation; either version 2.1
 *  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 Lesser General Public License for more details.
 *
 *  You should have received a copy of the GNU Lesser General Public License
 *  along with this program; if not, write to the Free Software
 *  Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 */
package org.openscience.cdk.pharmacophore;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

import org.openscience.cdk.CDKConstants;
import org.openscience.cdk.exception.CDKException;
import org.openscience.cdk.interfaces.IAtom;
import org.openscience.cdk.interfaces.IBond;

import nu.xom.Attribute;
import nu.xom.Builder;
import nu.xom.Document;
import nu.xom.Element;
import nu.xom.Elements;
import nu.xom.ParsingException;
import nu.xom.Serializer;

/**
 * Provides some utility methods for pharmacophore handling.
 *
 * @author Rajarshi Guha
 * @cdk.module pcore
 * @cdk.githash
 * @cdk.keyword pharmacophore
 * @cdk.keyword 3D isomorphism
 */
public class PharmacophoreUtils {

    /**
     * Read in a set of pharmacophore definitions to create pharmacophore queries.
     * 
     * Pharmacophore queries can be saved in an XML format which is described XXX. The
     * file can contain multiple definitions. This method will process all the definitions
     * and return a list fo {@link org.openscience.cdk.pharmacophore.PharmacophoreQuery} objects which can be used with
     * the {@link PharmacophoreMatcher} class.
     * 
     * The current schema for the document allows one to specify angle and distance
     * constraints. Currently the CDK does not support angle constraints, so they are
     * ignored.
     * 
     * The schema also specifies a <i>units</i> attribute for a given constraint. The
     * current reader ignores this and assumes that all distances are in Angstroms.
     * 
     * Finally, if there is a description associated with a pharmacophore definition, it is
     * available as the <i>"description"</i> property of the {@link org.openscience.cdk.pharmacophore.PharmacophoreQuery} object.
     * 
     * Example usage is
     * <pre>{@code
     * List<PharmacophoreQuery> defs = readPharmacophoreDefinitions("mydefs.xml");
     * System.out.println("Number of definitions = "+defs.size());
     * for (int i = 0; i < defs.size(); i++) {
     *     System.out.println("Desc: "+defs.get(i).getProperty("description");
     * }
     * }</pre>
     *
     * @param filename The file to read the definitions from
     * @return A list of {@link org.openscience.cdk.pharmacophore.PharmacophoreQuery} objects
     * @throws CDKException if there is an error in the format
     * @throws IOException  if there is an error in opening the file
     * @see org.openscience.cdk.pharmacophore.PharmacophoreQueryAtom
     * @see org.openscience.cdk.pharmacophore.PharmacophoreQueryBond
     * @see org.openscience.cdk.pharmacophore.PharmacophoreQuery
     * @see org.openscience.cdk.pharmacophore.PharmacophoreMatcher
     */
    public static List<PharmacophoreQuery> readPharmacophoreDefinitions(String filename) throws CDKException,
            IOException {
        Builder parser = new Builder();
        Document doc;
        try {
            doc = parser.build(filename);
        } catch (ParsingException e) {
            throw new CDKException("Invalid pharmacophore definition file");
        }
        return getdefs(doc);
    }

    /**
     * Read in a set of pharmacophore definitions to create pharmacophore queries.
     * 
     * Pharmacophore queries can be saved in an XML format which is described XXX. The
     * file can contain multiple definitions. This method will process all the definitions
     * and return a list of {@link org.openscience.cdk.pharmacophore.PharmacophoreQuery} objects which can be used with
     * the {@link PharmacophoreMatcher} class.
     * 
     * The current schema for the document allows one to specify angle and distance
     * constraints.
     * 
     * The schema also specifies a <i>units</i> attribute for a given constraint. The
     * current reader ignores this and assumes that all distances are in Angstroms and
     * angles are in degrees.
     * 
     * Finally, if there is a description associated with a pharmacophore definition, it is
     * available as the <i>"description"</i> property of the {@link org.openscience.cdk.pharmacophore.PharmacophoreQuery} object.
     * 
     * Example usage is
     * <pre>{@code
     * List<PharmacophoreQuery> defs = readPharmacophoreDefinitions"mydefs.xml");
     * System.out.println("Number of definitions = "+defs.size());
     * for (int i = 0; i < defs.size(); i++) {
     *     System.out.println("Desc: "+defs.get(i).getProperty("description");
     * }
     * }</pre>
     *
     * @param ins The stream to read the definitions from
     * @return A list of {@link org.openscience.cdk.pharmacophore.PharmacophoreQuery} objects
     * @throws CDKException if there is an error in the format
     * @throws IOException  if there is an error in opening the file
     * @see org.openscience.cdk.pharmacophore.PharmacophoreQueryAtom
     * @see org.openscience.cdk.pharmacophore.PharmacophoreQueryBond
     * @see org.openscience.cdk.pharmacophore.PharmacophoreMatcher
     * @see org.openscience.cdk.pharmacophore.PharmacophoreQuery
     */
    public static List<PharmacophoreQuery> readPharmacophoreDefinitions(InputStream ins) throws IOException,
            CDKException {
        Builder parser = new Builder();
        Document doc;
        try {
            doc = parser.build(ins);
        } catch (ParsingException e) {
            throw new CDKException("Invalid pharmacophore definition file");
        }
        return getdefs(doc);
    }

    /**
     * Write out one or more pharmacophore queries in the CDK XML format.
     *
     * @param query The pharmacophore queries
     * @param out   The OutputStream to write to
     * @throws IOException if there is a problem writing the XML document
     */
    public static void writePharmacophoreDefinition(PharmacophoreQuery query, OutputStream out) throws IOException {
        writePharmacophoreDefinition(new PharmacophoreQuery[]{query}, out);
    }

    /**
     * Write out one or more pharmacophore queries in the CDK XML format.
     *
     * @param queries The pharmacophore queries
     * @param out     The OutputStream to write to
     * @throws IOException if there is a problem writing the XML document
     */
    public static void writePharmacophoreDefinition(List<PharmacophoreQuery> queries, OutputStream out)
            throws IOException {
        writePharmacophoreDefinition(queries.toArray(new PharmacophoreQuery[]{}), out);
    }

    /**
     * Write out one or more pharmacophore queries in the CDK XML format.
     *
     * @param queries The pharmacophore queries
     * @param out     The OutputStream to write to
     * @throws IOException if there is a problem writing the XML document
     */
    public static void writePharmacophoreDefinition(PharmacophoreQuery[] queries, OutputStream out) throws IOException {
        Element root = new Element("pharmacophoreContainer");
        root.addAttribute(new Attribute("version", "1.0"));
        for (PharmacophoreQuery query : queries) {
            Element pcore = new Element("pharmacophore");

            Object description = query.getProperty("description");
            if (description != null) pcore.addAttribute(new Attribute("description", (String) description));

            Object name = query.getTitle();
            if (name != null) pcore.addAttribute(new Attribute("name", (String) name));

            // we add the pcore groups for this query as local to the group
            for (IAtom atom : query.atoms()) {
                Element group = new Element("group");
                group.addAttribute(new Attribute("id", atom.getSymbol()));
                group.appendChild(((PharmacophoreQueryAtom) atom).getSmarts());
                pcore.appendChild(group);
            }

            // now add the constraints
            for (IBond bond : query.bonds()) {
                Element elem = null;
                if (bond instanceof PharmacophoreQueryBond) {
                    PharmacophoreQueryBond dbond = (PharmacophoreQueryBond) bond;
                    elem = new Element("distanceConstraint");
                    elem.addAttribute(new Attribute("lower", String.valueOf(dbond.getLower())));
                    elem.addAttribute(new Attribute("upper", String.valueOf(dbond.getUpper())));
                    elem.addAttribute(new Attribute("units", "A"));
                } else if (bond instanceof PharmacophoreQueryAngleBond) {
                    PharmacophoreQueryAngleBond dbond = (PharmacophoreQueryAngleBond) bond;
                    elem = new Element("angleConstraint");
                    elem.addAttribute(new Attribute("lower", String.valueOf(dbond.getLower())));
                    elem.addAttribute(new Attribute("upper", String.valueOf(dbond.getUpper())));
                    elem.addAttribute(new Attribute("units", "degrees"));
                }

                // now add the group associated with this constraint
                for (IAtom iAtom : bond.atoms()) {
                    PharmacophoreQueryAtom atom = (PharmacophoreQueryAtom) iAtom;
                    Element gelem = new Element("groupRef");
                    gelem.addAttribute(new Attribute("id", atom.getSymbol()));
                    if (elem != null) {
                        elem.appendChild(gelem);
                    }
                }
                pcore.appendChild(elem);
            }
            root.appendChild(pcore);
        }
        Document doc = new Document(root);
        Serializer serializer = new Serializer(out, "ISO-8859-1");
        serializer.setIndent(4);
        serializer.setMaxLength(128);
        serializer.write(doc);
    }

    private static List<PharmacophoreQuery> getdefs(Document doc) throws CDKException {
        Element root = doc.getRootElement();

        // ltes get the children of the container
        // these will be either group or pharmacophore elems
        List<PharmacophoreQuery> ret = new ArrayList<PharmacophoreQuery>();

        // get global group defs
        HashMap<String, String> groups = getGroupDefinitions(root);

        //now get the pcore defs
        Elements children = root.getChildElements();
        for (int i = 0; i < children.size(); i++) {
            Element e = children.get(i);
            if (e.getQualifiedName().equals("pharmacophore")) ret.add(processPharmacophoreElement(e, groups));
        }
        return ret;
    }

    /*
     * find all <group> elements that are directly under the supplied element so
     * this wont recurse through sub elements that may contain group elements
     */
    private static HashMap<String, String> getGroupDefinitions(Element e) {
        HashMap<String, String> groups = new HashMap<String, String>();
        Elements children = e.getChildElements();
        for (int i = 0; i < children.size(); i++) {
            Element child = children.get(i);
            if (child.getQualifiedName().equals("group")) {
                String id = child.getAttributeValue("id").trim();
                String smarts = child.getValue().trim();
                groups.put(id, smarts);
            }
        }
        return groups;
    }

    /* process a single pcore definition */
    private static PharmacophoreQuery processPharmacophoreElement(Element e, HashMap<String, String> global)
            throws CDKException {
        PharmacophoreQuery ret = new PharmacophoreQuery();
        ret.setProperty("description", e.getAttributeValue("description"));
        ret.setTitle(e.getAttributeValue("name"));

        // first get any local group definitions
        HashMap<String, String> local = getGroupDefinitions(e);

        // now lets look at the constraints
        Elements children = e.getChildElements();
        for (int i = 0; i < children.size(); i++) {
            Element child = children.get(i);
            if (child.getQualifiedName().equals("distanceConstraint")) {
                processDistanceConstraint(child, local, global, ret);
            } else if (child.getQualifiedName().equals("angleConstraint")) {
                processAngleConstraint(child, local, global, ret);
            }
        }
        return ret;
    }

    private static void processDistanceConstraint(Element child, HashMap<String, String> local,
            HashMap<String, String> global, PharmacophoreQuery ret) throws CDKException {
        double lower;
        String tmp = child.getAttributeValue("lower");
        if (tmp == null)
            throw new CDKException("Must have a 'lower' attribute");
        else
            lower = Double.parseDouble(tmp);

        // we may not have an upper bound specified
        double upper;
        tmp = child.getAttributeValue("upper");
        if (tmp != null)
            upper = Double.parseDouble(tmp);
        else
            upper = lower;

        // now get the two groups for this distance
        Elements grouprefs = child.getChildElements();
        if (grouprefs.size() != 2) throw new CDKException("A distance constraint can only refer to 2 groups.");
        String id1 = grouprefs.get(0).getAttributeValue("id");
        String id2 = grouprefs.get(1).getAttributeValue("id");

        // see if it's a local def, else get it from the global list
        String smarts1, smarts2;
        if (local.containsKey(id1))
            smarts1 = local.get(id1);
        else if (global.containsKey(id1))
            smarts1 = global.get(id1);
        else
            throw new CDKException("Referring to a non-existant group definition");

        if (local.containsKey(id2))
            smarts2 = local.get(id2);
        else if (global.containsKey(id2))
            smarts2 = global.get(id2);
        else
            throw new CDKException("Referring to a non-existant group definition");

        // now see if we already have a correpsondiong pcore atom
        // else create a new atom
        if (!containsPatom(ret, id1)) {
            PharmacophoreQueryAtom pqa = new PharmacophoreQueryAtom(id1, smarts1);
            ret.addAtom(pqa);
        }
        if (!containsPatom(ret, id2)) {
            PharmacophoreQueryAtom pqa = new PharmacophoreQueryAtom(id2, smarts2);
            ret.addAtom(pqa);
        }

        // now add the constraint as a bond
        IAtom a1 = null, a2 = null;
        for (IAtom queryAtom : ret.atoms()) {
            if (queryAtom.getSymbol().equals(id1)) a1 = queryAtom;
            if (queryAtom.getSymbol().equals(id2)) a2 = queryAtom;
        }
        ret.addBond(new PharmacophoreQueryBond((PharmacophoreQueryAtom) a1, (PharmacophoreQueryAtom) a2, lower, upper));
    }

    private static void processAngleConstraint(Element child, HashMap<String, String> local,
            HashMap<String, String> global, PharmacophoreQuery ret) throws CDKException {
        double lower;
        String tmp = child.getAttributeValue("lower");
        if (tmp == null)
            throw new CDKException("Must have a 'lower' attribute");
        else
            lower = Double.parseDouble(tmp);

        // we may not have an upper bound specified
        double upper;
        tmp = child.getAttributeValue("upper");
        if (tmp != null)
            upper = Double.parseDouble(tmp);
        else
            upper = lower;

        // now get the three groups for this distance
        Elements grouprefs = child.getChildElements();
        if (grouprefs.size() != 3) throw new CDKException("An angle constraint can only refer to 3 groups.");
        String id1 = grouprefs.get(0).getAttributeValue("id");
        String id2 = grouprefs.get(1).getAttributeValue("id");
        String id3 = grouprefs.get(2).getAttributeValue("id");

        // see if it's a local def, else get it from the global list
        String smarts1, smarts2, smarts3;
        if (local.containsKey(id1))
            smarts1 = local.get(id1);
        else if (global.containsKey(id1))
            smarts1 = global.get(id1);
        else
            throw new CDKException("Referring to a non-existant group definition");

        if (local.containsKey(id2))
            smarts2 = local.get(id2);
        else if (global.containsKey(id2))
            smarts2 = global.get(id2);
        else
            throw new CDKException("Referring to a non-existant group definition");

        if (local.containsKey(id3))
            smarts3 = local.get(id3);
        else if (global.containsKey(id3))
            smarts3 = global.get(id3);
        else
            throw new CDKException("Referring to a non-existant group definition");

        // now see if we already have a correpsondiong pcore atom
        // else create a new atom
        if (!containsPatom(ret, id1)) {
            PharmacophoreQueryAtom pqa = new PharmacophoreQueryAtom(id1, smarts1);
            ret.addAtom(pqa);
        }
        if (!containsPatom(ret, id2)) {
            PharmacophoreQueryAtom pqa = new PharmacophoreQueryAtom(id2, smarts2);
            ret.addAtom(pqa);
        }
        if (!containsPatom(ret, id3)) {
            PharmacophoreQueryAtom pqa = new PharmacophoreQueryAtom(id3, smarts3);
            ret.addAtom(pqa);
        }

        // now add the constraint as a bond
        IAtom a1 = null, a2 = null;
        IAtom a3 = null;
        for (IAtom queryAtom : ret.atoms()) {
            if (queryAtom.getSymbol().equals(id1)) a1 = queryAtom;
            if (queryAtom.getSymbol().equals(id2)) a2 = queryAtom;
            if (queryAtom.getSymbol().equals(id3)) a3 = queryAtom;
        }
        ret.addBond(new PharmacophoreQueryAngleBond((PharmacophoreQueryAtom) a1, (PharmacophoreQueryAtom) a2,
                (PharmacophoreQueryAtom) a3, lower, upper));
    }

    private static boolean containsPatom(PharmacophoreQuery q, String id) {
        for (IAtom queryAtom : q.atoms()) {
            if (queryAtom.getSymbol().equals(id)) return true;
        }
        return false;
    }

}
