/*
 *    GeoTools - OpenSource mapping toolkit
 *    http://geotools.org
 *    (C) 2004-2006, GeoTools Project Managment Committee (PMC)
 *    (C) 2004, Institut de Recherche pour le Développement
 *   
 *    This library 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;
 *    version 2.1 of the License.
 *
 *    This library 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.
 */
package org.geotools.metadata.sql;

// J2SE dependencies
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URI;
import java.net.URISyntaxException;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.SortedSet;
import java.util.TreeSet;

// OpenGIS dependencies
import org.opengis.metadata.MetaData;
import org.opengis.util.CodeList;
import org.opengis.util.InternationalString;

// Geotools dependencies
import org.geotools.util.SimpleInternationalString;


/**
 * A connection to a metadata database. The metadata database can be created
 * using one of the scripts suggested in GeoAPI, for example
 * <code><A HREF="http://geoapi.sourceforge.net/snapshot/javadoc/org/opengis/metadata/doc-files/postgre/create.sql">create.sql</A></CODE>.
 * Then, in order to get for example a telephone number, the following code
 * may be used.
 *
 * <BLOCKQUOTE><PRE>
 * import org.opengis.metadata.citation.{@linkplain org.opengis.metadata.citation.Telephone Telephone};
 * ...
 * Connection     connection = ...
 * MetadataSource source     = new MetadataSource(connection);
 * Telephone      telephone  = (Telephone) source.getEntry(Telephone.class, id);
 * </PRE></BLOCKQUOTE>
 *
 * where {@code id} is the primary key value for the desired record in the
 * {@code CI_Telephone} table.
 *
 * @author Touraïvane
 * @author Olivier Kartotaroeno
 * @author Martin Desruisseaux
 *
 * @since 2.1
 * @source $URL: http://svn.geotools.org/geotools/tags/2.4.1/modules/library/metadata/src/main/java/org/geotools/metadata/sql/MetadataSource.java $
 */
public class MetadataSource {
    /**
     * The package for metadata <strong>interfaces</strong> (not the implementation).
     */
    final String metadataPackage = "org.opengis.metadata.";

    /**
     * The connection to the database.
     */
    private final Connection connection;

    /**
     * The SQL query to use for fetching the attribute in a specific row.
     * The first question mark is the table name to search into; the second
     * one is the primary key of the record to search.
     */
    private final String query = "SELECT * FROM metadata.\"?\" WHERE id=?";

    /**
     * The SQL query to use for fetching a code list element.
     * The first question mark is the table name to search into;
     * the second one is the primary key of the element to search.
     */
    private final String codeQuery = "SELECT name FROM metadata.\"?\" WHERE code=?";

    /**
     * The prepared statements created is previous call to {@link #getValue}.
     * Those statements are encapsulated into {@link MetadataResult} objects.
     */
    private final Map statements = new HashMap();

    /**
     * The map from GeoAPI names to ISO names. For example the GeoAPI
     * {@link org.opengis.metadata.citation.Citation} interface maps
     * to the ISO 19115 {@code CI_Citation} name.
     */
    private final Properties geoApiToIso = new Properties();

    /**
     * Type of collections.
     */
    private final Properties collectionTypes = new Properties();

    /**
     * The class loader to use for proxy creation.
     */
    private final ClassLoader loader;
    
    /**
     * Creates a new metadata source.
     *
     * @param connection The connection to the database.
     */
    public MetadataSource(final Connection connection) {
        this.connection = connection;
        try {
            InputStream in = MetaData.class.getResourceAsStream("GeoAPI_to_ISO.properties");
            geoApiToIso.load(in);
            in.close();
            in = MetaData.class.getResourceAsStream("CollectionTypes.properties");
            // TODO: remove the (!= null) check after the next geoapi update.
            if (in != null) {
                collectionTypes.load(in);
                in.close();
            }
        } catch (IOException exception) {
            /*
             * Note: we do not expose the checked IOException because in a future
             *       version (when we will be allowed to use J2SE 1.5), it should
             *       disaspear. This is because a J2SE 1.5 enabled version should
             *       use method's annotations instead.
             */
            throw new MetadataException("Can't read resources.", exception); // TODO: localize
        }
        loader = getClass().getClassLoader();
    }

    /**
     * Returns an implementation of the specified metadata interface filled
     * with the data referenced by the specified identifier. Alternatively,
     * this method can also returns a {@link CodeList} element.
     *
     * @param  type The interface to implement (e.g.
     *         {@link org.opengis.metadata.citation.Citation}), or
     *         the {@link CodeList}.
     * @param  identifier The identifier used in order to locate the record for
     *         the metadata entity to be created. This is usually the primary key
     *         of the record to search for.
     * @return An implementation of the required interface, or the code list element.
     * @throws SQLException if a SQL query failed.
     */
    public synchronized Object getEntry(final Class type, final String identifier)
            throws SQLException
    {
        if (CodeList.class.isAssignableFrom(type)) {
            return getCodeList(type, identifier);
        }
        return Proxy.newProxyInstance(loader, new Class[] {type},
                                      new MetadataEntity(identifier, this));
    }

    /**
     * Returns an attribute from a table.
     *
     * @param  type       The interface class. This is mapped to the table name in the database.
     * @param  method     The method invoked. This is mapped to the column name in the database.
     * @param  identifier The primary key of the record to search for.
     * @return The value of the requested attribute.
     * @throws SQLException if the SQL query failed.
     */
    final synchronized Object getValue(final Class type, final Method method, final String identifier)
            throws SQLException
    {
        final String className = getClassName(type);
        MetadataResult result = (MetadataResult) statements.get(type);
        if (result == null) {
            result = new MetadataResult(connection, query, getTableName(className));
            statements.put(type, result);
        }
        final String columnName = getColumnName(className, method);
        final Class  valueType  = method.getReturnType();
        /*
         * Process the ResultSet value according the expected return type. If a collection
         * is expected, then assumes that the ResultSet contains an array and invokes the
         * 'getValue' method for each element.
         */
        if (Collection.class.isAssignableFrom(valueType)) {
            final Collection collection;
            if (List.class.isAssignableFrom(valueType)) {
                collection = new ArrayList();
            } else if (SortedSet.class.isAssignableFrom(valueType)) {
                collection = new TreeSet();
            } else {
                collection = new LinkedHashSet();
            }
            assert valueType.isAssignableFrom(collection.getClass());
            final Object elements = result.getArray(identifier, columnName);
            if (elements != null) {
                final Class  elementType = getElementType(className, method);
                final boolean isMetadata = isMetadata(elementType);
                final int         length = Array.getLength(elements);
                for (int i=0; i<length; i++) {
                    collection.add(isMetadata ? getEntry(elementType, Array.get(elements, i).toString())
                                              : convert (elementType, Array.get(elements, i)));
                }
            }
            return collection;
        }
        /*
         * If a GeoAPI interface or a code list is expected, then assumes that the ResultSet
         * value is a foreigner key. Queries again the database in the foreigner table.
         */
        if (valueType.isInterface() && isMetadata(valueType)) {
            final String foreigner = result.getString(identifier, columnName);
            return result.wasNull() ? null : getEntry(valueType, foreigner);
        }
        if (CodeList.class.isAssignableFrom(valueType)) {
            final String foreigner = result.getString(identifier, columnName);
            return result.wasNull() ? null : getCodeList(valueType, foreigner);
        }
        /*
         * Not a foreigner key. Get the value and transform it to the
         * espected type, if needed.
         */
        return convert(valueType, result.getObject(identifier, columnName));
    }

    /**
     * Returns {@code true} if the specified type belong to the metadata package.
     */
    private boolean isMetadata(final Class valueType) {
        return valueType.getName().startsWith(metadataPackage);
    }

    /**
     * Converts the specified non-metadata value into an object of the expected type.
     * The expected value is an instance of a class outside the metadata package, for
     * example {@link String}, {@link InternationalString}, {@link URI}, etc.
     */
    private static Object convert(final Class valueType, final Object value) {
        if (value!=null && !valueType.isAssignableFrom(value.getClass())) {
            if (InternationalString.class.isAssignableFrom(valueType)) {
               return new SimpleInternationalString(value.toString());
            }
            if (URL.class.isAssignableFrom(valueType)) try {
                return new URL(value.toString());
            } catch (MalformedURLException exception) {
                // TODO: localize and provides more details.
                throw new MetadataException("Illegal value.", exception);
            }
            if (URI.class.isAssignableFrom(valueType)) try {
                return new URI(value.toString());
            } catch (URISyntaxException exception) {
                // TODO: localize and provides more details.
                throw new MetadataException("Illegal value.", exception);
            }
        }
        return value;
    }

    /**
     * Returns a code list of the given type.
     *
     * @param  type The type, as a subclass of {@link CodeList}.
     * @param  identifier The identifier in the code list. This method accepts either The numerical
     *         value of the code to search for (usually the primary key), or the code name.
     * @return The code list element.
     * @throws SQLException if a SQL query failed.
     */
    private CodeList getCodeList(final Class type, String identifier) throws SQLException {
        assert Thread.holdsLock(this);
        final String className = getClassName(type);
        int     code;          // The identifier as an integer.
        boolean isNumerical;   // 'true' if 'code' is valid.
        try {
            code = Integer.parseInt(identifier);
            isNumerical = true;
        } catch (NumberFormatException exception) {
            code = 0;
            isNumerical = false;
        }
        /*
         * Converts the numerical value into the code list name.
         */
        if (isNumerical) {
            MetadataResult result = (MetadataResult) statements.get(type);
            if (result == null) {
                result = new MetadataResult(connection, codeQuery, getTableName(className));
                statements.put(type, result);
            }
            identifier = result.getString(identifier);
        }
        /*
         * Search a code list with the same name than the one declared
         * in the database. We will use name instead of code numerical
         * value, since the later is more bug prone.
         */
        final CodeList[] values;
        try {
            values = (CodeList[]) type.getMethod("values", (Class []) null)
                                      .invoke   (null,     (Object[]) null);
        } catch (NoSuchMethodException exception) {
            throw new MetadataException("Can't read code list.", exception); // TODO: localize
        } catch (IllegalAccessException exception) {
            throw new MetadataException("Can't read code list.", exception); // TODO: localize
        } catch (InvocationTargetException exception) {
            throw new MetadataException("Can't read code list.", exception); // TODO: localize
        }
        CodeList candidate;
        final StringBuffer candidateName = new StringBuffer(className);
        candidateName.append('.');
        final int base = candidateName.length();
        if (code>=1 && code<values.length) {
            candidate = values[code-1];
            candidateName.append(candidate.name());
            if (identifier.equals(geoApiToIso.getProperty(candidateName.toString()))) {
                return candidate;
            }
        }
        /*
         * The previous code was an optimization which checked directly the code list
         * for the same code than the one used in the database. Most of the time, the
         * name matches and this loop is never executed. If we reach this point, then
         * maybe the numerical code are not the same in the database than in the Java
         * CodeList implementation. Check each code list element by name.
         */
        for (int i=0; i<values.length; i++) {
            candidate = values[i];
            candidateName.setLength(base);
            candidateName.append(candidate.name());
            if (identifier.equals(geoApiToIso.getProperty(candidateName.toString()))) {
                return candidate;
            }
        }
        // TODO: localize
        throw new SQLException("Unknow code list: \""+identifier+"\" in table \"" + 
                               getTableName(className)+'"');
    }

    /**
     * Returns the unqualified Java interface name for the specified type.
     * This is usually the GeoAPI name.
     */
    private static String getClassName(final Class type) {
        final String className = type.getName();
        return className.substring(className.lastIndexOf('.') + 1);
    }

    /**
     * Returns the table name for the specified class.
     * This is usually the ISO 19115 name.
     */
    private String getTableName(final String className) {
        final String tableName = geoApiToIso.getProperty(className);
        return (tableName != null) ? tableName : className;
    }

    /**
     * Returns the column name for the specified method.
     */
    private String getColumnName(final String className, final Method method) {
        final String methodName = method.getName();
        final String columnName = geoApiToIso.getProperty(className+'.'+methodName);
        return (columnName != null) ? columnName : methodName;
    }

    /**
     * Returns the element type in collection for the specified method.
     */
    private Class getElementType(final String className, final Method method) {
        final String key = className+'.'+method.getName();
        final String typeName = collectionTypes.getProperty(key);
        Exception cause = null;
        if (typeName != null) try {
            return Class.forName(typeName);
        } catch (ClassNotFoundException exception) {
            cause = exception;
        }
        // TODO: localize.
        final MetadataException e = new MetadataException("Unknow element type for "+key);
        if (cause != null) {
            e.initCause(cause);
        }
        throw e;
    }

    /**
     * Close all connections used in this object.
     */
    public synchronized void close() throws SQLException {
        for (final Iterator it=statements.values().iterator(); it.hasNext();) {
            ((MetadataResult)it.next()).close();
            it.remove();
        }
        connection.close();
    }    
}
