/*
 *    GeoTools - OpenSource mapping toolkit
 *    http://geotools.org
 *    (C) 2007, GeoTools Project Managment Committee (PMC)
 *    (C) 2007, Geomatys
 *
 *    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.image.io.netcdf;

// J2SE dependencies
import java.text.DateFormat;
import java.text.Format;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import javax.imageio.ImageReader;

// OpenGIS dependencies
import org.opengis.referencing.cs.AxisDirection;

// NetCDF dependencies
import ucar.nc2.Variable;
import ucar.nc2.Attribute;
import ucar.nc2.dataset.AxisType;
import ucar.nc2.dataset.CoordinateAxis;
import ucar.nc2.dataset.CoordinateAxis1D;
import ucar.nc2.dataset.CoordinateSystem;
import ucar.nc2.dataset.NetcdfDataset;
import ucar.nc2.dataset.VariableDS;

// Geotools dependencies
import org.geotools.image.io.metadata.Axis;
import org.geotools.image.io.metadata.ImageGeometry;
import org.geotools.image.io.metadata.ImageReferencing;
import org.geotools.image.io.metadata.MetadataAccessor;
import org.geotools.image.io.metadata.GeographicMetadata;
import org.geotools.image.io.metadata.GeographicMetadataFormat;
import org.geotools.util.logging.LoggedFormat;
import org.geotools.resources.i18n.Errors;
import org.geotools.resources.i18n.ErrorKeys;


/**
 * Metadata from NetCDF file. This implementation assumes that the NetCDF file follows the
 * <A HREF="http://www.cfconventions.org/">CF Metadata conventions</A>.
 * <p>
 * <b>Limitation:</b>
 * Current implementation retains only the first {@linkplain CoordinateSystem coordinate system}
 * found in the NetCDF file or for a given variable. The {@link org.geotools.coverage.io} package
 * would not know what to do with the extra coordinate systems anyway.
 *
 * @since 2.4
 * @source $URL: http://svn.geotools.org/geotools/tags/2.4.1/modules/unsupported/coverageio-netcdf/src/main/java/org/geotools/image/io/netcdf/NetcdfMetadata.java $
 * @version $Id: NetcdfMetadata.java 27848 2007-11-12 13:10:32Z desruisseaux $
 * @author Martin Desruisseaux
 */
public class NetcdfMetadata extends GeographicMetadata {
    /**
     * Forces usage of UCAR libraries in some places where we use our own code instead.
     * This may result in rounding errors and absence of information regarding fill values,
     * but is useful for checking if we are doing the right thing compared to the UCAR way.
     */
    private static final boolean USE_UCAR_LIB = false;

    /**
     * The mapping between UCAR axis type and ISO axis directions.
     */
    private static final Map/*<AxisType,AxisDirection>*/ DIRECTIONS = new HashMap(16);
    static {
        add(AxisType.Time,     AxisDirection.FUTURE);
        add(AxisType.GeoX,     AxisDirection.EAST);
        add(AxisType.GeoY,     AxisDirection.NORTH);
        add(AxisType.GeoZ,     AxisDirection.UP);
        add(AxisType.Lat,      AxisDirection.NORTH);
        add(AxisType.Lon,      AxisDirection.EAST);
        add(AxisType.Height,   AxisDirection.UP);
        add(AxisType.Pressure, AxisDirection.UP);
    }

    /**
     * Adds a mapping between UCAR type and ISO direction.
     */
    private static void add(final AxisType type, final AxisDirection direction) {
        if (DIRECTIONS.put(type, direction) != null) {
            throw new IllegalArgumentException(String.valueOf(type));
        }
    }

    /**
     * Creates metadata from the specified file. This constructor is typically invoked
     * for creating {@linkplain NetcdfReader#getStreamMetadata stream metadata}. Note that
     * {@link ucar.nc2.dataset.CoordSysBuilder#addCoordinateSystems} should have been invoked
     * (if needed) before this constructor.
     */
    public NetcdfMetadata(final ImageReader reader, final NetcdfDataset file) {
        super(reader);
        final List/*<CoordinateSystem>*/ systems = file.getCoordinateSystems();
        if (!systems.isEmpty()) {
            addCoordinateSystem((CoordinateSystem) systems.get(0));
        }
    }

    /**
     * Creates metadata from the specified file. This constructor is typically invoked
     * for creating {@linkplain NetcdfReader#getImageMetadata image metadata}. Note that
     * {@link ucar.nc2.dataset.CoordSysBuilder#addCoordinateSystems} should have been invoked
     * (if needed) before this constructor.
     */
    public NetcdfMetadata(final ImageReader reader, final VariableDS variable) {
        super(reader);
        final List/*<CoordinateSystem>*/ systems = variable.getCoordinateSystems();
        if (!systems.isEmpty()) {
            addCoordinateSystem((CoordinateSystem) systems.get(0));
        }
        setSampleType(GeographicMetadataFormat.PACKED);
        addSampleDimension(variable);
    }

    /**
     * Adds the specified coordinate system. Current implementation can adds at most one
     * coordinate system, but this limitation may be revisited in a future Geotools version.
     *
     * @param cs The coordinate system to add.
     */
    public void addCoordinateSystem(final CoordinateSystem cs) {
        String crsType, csType;
        if (cs.isLatLon()) {
            crsType = cs.hasVerticalAxis() ? GeographicMetadataFormat.GEOGRAPHIC_3D
                                           : GeographicMetadataFormat.GEOGRAPHIC;
            csType  = GeographicMetadataFormat.ELLIPSOIDAL;
        } else if (cs.isGeoXY()) {
            crsType = cs.hasVerticalAxis() ? GeographicMetadataFormat.PROJECTED_3D
                                           : GeographicMetadataFormat.PROJECTED;
            csType  = GeographicMetadataFormat.CARTESIAN;
        } else {
            crsType = null;
            csType  = null;
        }
        final ImageReferencing referencing = getReferencing();
        referencing.setCoordinateReferenceSystem(null, crsType);
        referencing.setCoordinateSystem(cs.getName(), csType);
        final ImageGeometry geometry = getGeometry();
        geometry.setPixelOrientation("center");
        /*
         * Adds the axis in reverse order, because the NetCDF image reader put the last
         * dimensions in the rendered image. Typical NetCDF convention is to put axis in
         * the (time, depth, latitude, longitude) order, which typically maps to
         * (longitude, latitude, depth, time) order in Geotools referencing framework.
         */
        final List/*<CoordinateAxis>*/ axis = cs.getCoordinateAxes();
        for (int i=axis.size(); --i>=0;) {
            addCoordinateAxis((CoordinateAxis) axis.get(i));
        }
    }

    /**
     * Gets the name, as the "description", "title" or "standard name"
     * attribute if possible, or as the variable name otherwise.
     */
    private static String getName(final Variable variable) {
        String name = variable.getDescription();
        if (name == null || (name=name.trim()).length() == 0) {
            name = variable.getName();
        }
        return name;
    }

    /**
     * Adds the specified coordinate axis. This method is invoked recursively
     * by {@link #addCoordinateSystem}.
     *
     * @param axis The axis to add.
     */
    public void addCoordinateAxis(final CoordinateAxis axis) {
        final String name = getName(axis);
        final AxisType type = axis.getAxisType();
        String units = axis.getUnitsString();
        /*
         * Gets the axis direction, taking in account the possible reversal or vertical axis.
         * Note that geographic and projected CRS have the same directions. We can distinguish
         * them either using the ISO CRS type ("geographic" or "projected"), the ISO CS type
         * ("ellipsoidal" or "cartesian") or the units ("degrees" or "m").
         */
        String direction = null;
        AxisDirection directionCode = (AxisDirection) DIRECTIONS.get(type);
        if (directionCode != null) {
            if (CoordinateAxis.POSITIVE_DOWN.equalsIgnoreCase(axis.getPositive())) {
                directionCode = directionCode.opposite();
            }
            direction = directionCode.name();
            final int offset = units.lastIndexOf('_');
            if (offset >= 0) {
                final String unitsDirection = units.substring(offset + 1).trim();
                final String opposite = directionCode.opposite().name();
                if (unitsDirection.equalsIgnoreCase(opposite)) {
                    warning("addCoordinateAxis", ErrorKeys.INCONSISTENT_AXIS_ORIENTATION_$2,
                            new String[] {name, direction});
                    direction = opposite;
                }
                if (unitsDirection.equalsIgnoreCase(direction)) {
                    units = units.substring(0, offset).trim();
                }
            }
        }
        /*
         * Gets the axis origin. In the particular case of time axis, units are typically
         * written in the form "days since 1990-01-01 00:00:00". We extract the part before
         * "since" as the units and the part after "since" as the date.
         */
        final Axis axisNode = getReferencing().addAxis(name, direction, units);
        if (AxisType.Time.equals(type)) {
            String origin = null;
            final String[] unitsParts = units.split("(?i)\\s+since\\s+");
            if (unitsParts.length == 2) {
                units  = unitsParts[0].trim();
                origin = unitsParts[1].trim();
            } else {
                final Attribute attribute = axis.findAttribute("time_origin");
                if (attribute != null) {
                    origin = attribute.getStringValue();
                }
            }
            Date epoch = null;
            if (origin != null) {
                origin = MetadataAccessor.trimFractionalPart(origin);
                epoch = (Date) parse(type, origin, Date.class, "addCoordinateAxis");
            }
            axisNode.setTimeOrigin(epoch);
            axisNode.setUnits(units);
        }
        /*
         * If the axis is not numeric, we can't process any further.
         * If it is, then adds the coordinate and index ranges.
         */
        if (!axis.isNumeric()) {
            return;
        }
        if (axis instanceof CoordinateAxis1D) {
            final CoordinateAxis1D axis1D = (CoordinateAxis1D) axis;
            final ImageGeometry geometry = getGeometry();
            final int length = axis1D.getDimension(0).getLength();
            if (length > 2 && axis1D.isRegular()) {
                // Reminder: pixel orientation is "center", maximum value is inclusive.
                final double increment = axis1D.getIncrement();
                final double start     = axis1D.getStart();
                final double end       = start + increment * (length - 1); // Inclusive
                geometry.addCoordinateRange(0, length-1, start, end);
            } else {
                final double[] values = axis1D.getCoordValues();
                geometry.addCoordinateValues(0, values);
            }
        }
    }

    /**
     * Adds sample dimension information for the specified variable.
     *
     * @param variable The variable to add as a sample dimension.
     */
    public void addSampleDimension(final VariableDS variable) {
        final VariableMetadata m;
        if (USE_UCAR_LIB) {
            m = new VariableMetadata(variable);
        } else {
            m = new VariableMetadata(variable, forcePacking("valid_range"));
        }
        m.copyTo(addBand(getName(variable)));
    }

    /**
     * Parses the given string as a value along the specified axis.
     *
     * @param  type     The type of the axis.
     * @param  value    The value along that axis.
     * @param  expected The expected type.
     * @return The value after parsing.
     */
    private Object /*<T>*/ parse(final AxisType type, String value,
                                 final Class/*<T>*/ expected, final String caller)
    {
        final LoggedFormat format = createLoggedFormat(getAxisFormat(type, value), expected);
        format.setLogger("org.geotools.image.io.netcdf");
        format.setCaller(NetcdfMetadata.class, caller);
        return format.parse(value);
    }

    /**
     * Returns a format to use for parsing values along the specified axis type. This method
     * is invoked when parsing the date part of axis units like "<cite>days since 1990-01-01
     * 00:00:00</cite>". Subclasses should override this method if the date part is formatted
     * in a different way. The default implementation returns the following formats:
     * <p>
     * <ul>
     *   <li>For {@linkplain AxisType#Time time axis}, a {@link DateFormat} using the
     *       {@code "yyyy-MM-dd HH:mm:ss"} pattern in UTC {@linkplain TimeZone timezone}.</li>
     *   <li>For all other kind of axis, a {@link NumberFormat}.</li>
     * </ul>
     * <p>
     * The {@linkplain Locale#CANADA Canada locale} is used by default for most formats because
     * it is relatively close to ISO (for example regarding days and months order in dates) while
     * using the English symbols.
     *
     * @param  type The type of the axis.
     * @param  prototype An example of the values to be parsed. Implementations may parse this
     *         prototype when the axis type alone is not suffisient. For example the {@linkplain
     *         AxisType#Time time axis type} should uses the {@code "yyyy-MM-dd"} date pattern,
     *         but some files do not follow this convention and use the default local instead.
     * @return The format for parsing values along the axis.
     */
    protected Format getAxisFormat(final AxisType type, final String prototype) {
        if (!type.equals(AxisType.Time)) {
            return NumberFormat.getNumberInstance(Locale.CANADA);
        }
        char dateSeparator = '-';   // The separator used in ISO format.
        boolean yearLast   = false; // Year is first in ISO pattern.
        boolean namedMonth = false; // Months are numbers in the ISO pattern.
        if (prototype != null) {
            /*
             * Performs a quick check on the prototype content. If the prototype seems to use a
             * different date separator than the ISO one, we will adjust the pattern accordingly.
             * Also checks if the year seems to appears last rather than first, and if the month
             * seems to be written using letters rather than digits.
             */
            int field = 1;
            int digitCount = 0;
            final int length = prototype.length();
            for (int i=0; i<length; i++) {
                final char c = prototype.charAt(i);
                if (Character.isWhitespace(c)) {
                    break; // Checks only the dates, ignore the hours.
                }
                if (Character.isDigit(c)) {
                    digitCount++;
                    continue; // Digits are legal in all cases.
                }
                if (field == 2 && Character.isLetter(c)) {
                    namedMonth = true;
                    continue; // Letters are legal for month only.
                }
                if (field == 1) {
                    dateSeparator = c;
                }
                digitCount = 0;
                field++;
            }
            if (digitCount >= 4) {
                yearLast = true;
            }
        }
        String pattern;
        if (yearLast) {
            pattern = namedMonth ? "dd-MMM-yyyy" : "dd-MM-yyyy";
        } else {
            pattern = namedMonth ? "yyyy-MMM-dd" : "yyyy-MM-dd";
        }
        pattern = pattern.replace('-', dateSeparator);
        pattern += " HH:mm:ss";
        final DateFormat format = new SimpleDateFormat(pattern, Locale.CANADA);
        format.setTimeZone(TimeZone.getTimeZone("UTC"));
        return format;
    }

    /**
     * Returns {@code true} if an attribute (usually the <cite>valid range</cite>) should be
     * converted from unpacked to packed units. The <A HREF="http://www.cfconventions.org/">CF
     * Metadata conventions</A> states that valid ranges should be in packed units, but not
     * every NetCDF files follow this advice in practice. The UCAR NetCDF library applies the
     * following heuristic rules (quoting from {@link ucar.nc2.dataset.EnhanceScaleMissing}):
     *
     * <blockquote>
     * If {@code valid_range} is the same type as {@code scale_factor} (actually the wider of
     * {@code scale_factor} and {@code add_offset}) and this is wider than the external data,
     * then it will be interpreted as being in the units of the internal (unpacked) data.
     * Otherwise it is in the units of the external (packed) data.
     * <blockquote>
     *
     * However some NetCDF files stores unpacked ranges using the same type than packed data.
     * The above cited heuristic rule can not resolve those cases.
     * <p>
     * If this method returns {@code true}, then the attribute is assumed in unpacked units no
     * matter what the CF convention and the heuristic rules said. If this method returns
     * {@code false}, then UCAR's heuristic rules applies.
     * <p>
     * The default implementation returns {@code false} in all cases.
     *
     * @param  attribute The attribute (usually {@code "valid_range"}).
     * @return {@code true} if the attribute should be converted from unpacked to packed units
     *         regardless CF convention and UCAR's heuristic rules.
     *
     * @see ucar.nc2.dataset.EnhanceScaleMissing
     */
    protected boolean forcePacking(final String attribute) {
        return false;
    }

    /**
     * Convenience method for logging a warning.
     */
    private void warning(final String method, final int key, final Object value) {
        final LogRecord record = Errors.getResources(getLocale()).
                getLogRecord(Level.WARNING, key, value);
        record.setSourceClassName(NetcdfMetadata.class.getName());
        record.setSourceMethodName(method);
        warningOccurred(record);
    }
}
