/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 org.apache.sis.internal.earth.netcdf;

import java.util.Set;
import java.util.Map;
import java.util.HashMap;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.regex.Pattern;
import org.opengis.referencing.crs.ProjectedCRS;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.TransformException;
import org.apache.sis.storage.netcdf.AttributeNames;
import org.apache.sis.internal.netcdf.Convention;
import org.apache.sis.internal.netcdf.Decoder;
import org.apache.sis.internal.netcdf.Variable;
import org.apache.sis.internal.netcdf.VariableRole;
import org.apache.sis.internal.netcdf.Linearizer;
import org.apache.sis.internal.netcdf.Node;
import org.apache.sis.internal.referencing.provider.Sinusoidal;
import org.apache.sis.internal.referencing.provider.Equirectangular;
import org.apache.sis.internal.referencing.provider.PolarStereographicA;
import org.apache.sis.referencing.operation.transform.TransferFunction;
import org.apache.sis.referencing.operation.transform.MathTransforms;
import org.apache.sis.referencing.operation.matrix.Matrix3;
import org.apache.sis.referencing.CommonCRS;
import org.apache.sis.measure.NumberRange;
import ucar.nc2.constants.CF;


/**
 * Global Change Observation Mission - Climate (GCOM-C) conventions. This class provides customizations to the netCDF reader
 * for decoding <cite>Shikisai</cite> GCOM-C files produced by Japan Aerospace Exploration Agency (JAXA), version 1.00.
 * The file format is HDF5 and variables are like below (simplified):
 *
 * {@preformat text
 *     group: Geometry_data {
 *         variables:
 *             float Latitude(161, 126)
 *                 string Unit = "degree"
 *                 string Dim0 = "Line grids"
 *                 string Dim1 = "Pixel grids"
 *                 int Resampling_interval = 10
 *             float Longitude(161, 126)
 *                 string Unit = "degree"
 *                 string Dim0 = "Line grids"
 *                 string Dim1 = "Pixel grids"
 *                 int Resampling_interval = 10
 *     }
 *     group: Image_data {
 *         variables:
 *             ushort SST(1599, 1250)                   // Note: different size than (latitude, longitude) variables.
 *                 string dim0 = "Line grids"
 *                 string dim1 = "Piexl grids"              // Note: typo in "Pixel"
 *                 ushort Error_DN           = 65535
 *                 ushort Land_DN            = 65534
 *                 ushort Cloud_error_DN     = 65533
 *                 ushort Retrieval_error_DN = 65532
 *                 ushort Maximum_valid_DN   = 65531
 *                 ushort Minimum_valid_DN   = 0
 *                 float  Slope              = 0.0012
 *                 float  Offset             = -10
 *                 string Unit               = "degree"
 *     }
 *     group: Global_attributes {
 *         string :Algorithm_developer = "Japan Aerospace Exploration Agency (JAXA)"
 *         string :Dataset_description = "Sea surface temperatures determined by using the TIR 1 and 2 data of SGLI"
 *         string :Satellite           = "Global Change Observation Mission - Climate (GCOM-C)"
 *         string :Scene_start_time    = "20181201 09:14:16.797"
 *         string :Scene_end_time      = "20181201 09:18:11.980"
 *     }
 *     group: Processing_attributes {
 *         string :Contact_point = "JAXA/Earth Observation Research Center (EORC)"
 *         string :Processing_organization = "JAXA/GCOM-C science project"
 *         string :Processing_UT = "20181202 04:42:09"
 *     }
 * }
 *
 * Observations:
 * <ul class="verbose">
 *   <li>There is no {@code convention} attribute, so we have to rely on something else for detecting this convention.</li>
 *   <li>The size of latitude and longitude variables is not the same than the size of image data.
 *       This particularity is handled by {@link #gridToDataIndices(Variable)}.</li>
 *   <li>The {@code dim0} and {@code dim1} attribute names in image data have a different case
 *       than the attributes in longitude and latitude variables. Furthermore a value contains a typo.
 *       This particularity is handled by {@link #nameOfDimension(Variable, int)}.</li>
 *   <li>The {@code Slope} and {@code Offset} attribute names are different than the names defined in CF-Convention.
 *       This particularity is handled by {@link #transferFunction(Variable)}.</li>
 *   <li>There is more than one sentinel values for missing values.
 *       All attribute names for missing values have {@code "_DN"} suffix.
 *       This particularity is handled by {@link #nodataValues(Variable)}.</li>
 *   <li>The global attributes have different names than CF-Convention.
 *       This particularity is handled by {@link #mapAttributeName(String)}.</li>
 * </ul>
 *
 * @author  Alexis Manin (Geomatys)
 * @author  Martin Desruisseaux (Geomatys)
 * @version 1.0
 *
 * @see <a href="http://global.jaxa.jp/projects/sat/gcom_c/">SHIKISAI (GCOM-C) on JAXA</a>
 * @see <a href="https://en.wikipedia.org/wiki/Global_Change_Observation_Mission">GCOM on Wikipedia</a>
 *
 * @since 1.0
 * @module
 */
public final class GCOM_C extends Convention {
    /**
     * Sentinel value to search in the {@code "Satellite"} attribute for determining if GCOM-C conventions apply.
     */
    private static final Pattern SENTINEL_VALUE = Pattern.compile(".*\\bGCOM-C\\b.*");

    /**
     * Mapping from ACDD or CF-Convention attribute names to names of attributes used by GCOM-C.
     */
    private static final Map<String,String> ATTRIBUTES;
    static {
        final Map<String,String> m = new HashMap<>();
        m.put(AttributeNames.TITLE,               "Product_name");             // identification­Info / citation / title
        m.put(AttributeNames.PRODUCT_VERSION,     "Product_version");          // identification­Info / citation / edition
        m.put(AttributeNames.IDENTIFIER.TEXT,     "Product_file_name");        // identification­Info / citation / identifier / code
        m.put(AttributeNames.DATE_CREATED,        "Processing_UT");            // identification­Info / citation / date
        m.put(AttributeNames.CREATOR.INSTITUTION, "Processing_organization");  // identification­Info / citation / citedResponsibleParty
        m.put(AttributeNames.SUMMARY,             "Dataset_description");      // identification­Info / abstract
        m.put(AttributeNames.PLATFORM.TEXT,       "Satellite");                // acquisition­Information / platform / identifier
        m.put(AttributeNames.INSTRUMENT.TEXT,     "Sensor");                   // acquisition­Information / platform / instrument / identifier
        m.put(AttributeNames.PROCESSING_LEVEL,    "Product_level");            // content­Info / processing­Level­Code
        m.put(AttributeNames.SOURCE,              "Input_files");              // data­Quality­Info / lineage / source / description
        m.put(AttributeNames.TIME.MINIMUM,        "Scene_start_time");         // identification­Info / extent / temporal­Element / extent
        m.put(AttributeNames.TIME.MAXIMUM,        "Scene_end_time");           // identification­Info / extent / temporal­Element / extent
        ATTRIBUTES = m;
    }

    /**
     * Name of the group defining map projection parameters, localization grid, <i>etc</i>.
     */
    private static final String GEOMETRY_DATA = "Geometry_data";

    /**
     * Names of attributes for sample values having "no-data" meaning.
     * All those names have {@value #SUFFIX} suffix.
     */
    private static final String[] NO_DATA = {
        "Error_DN",
        "Land_DN",
        "Cloud_error_DN",
        "Retrieval_error_DN"
    };

    /**
     * Suffix of all attribute names enumerated in {@link #NO_DATA}.
     */
    private static final String SUFFIX = "_DN";

    /**
     * Creates a new instance of GCOM-C conventions.
     */
    public GCOM_C() {
    }

    /**
     * Detects if this set of conventions applies to the given netCDF file.
     * This methods fetches the {@code "Global_attributes/Satellite"} attribute,
     * which is expected to contain the following value:
     *
     * <blockquote>Global Change Observation Mission - Climate (GCOM-C)</blockquote>
     *
     * We test only for presence of {@code "GCOM-C"} in order to allow
     * for some variations in exact text content.
     *
     * @param  decoder  the netCDF file to test.
     * @return {@code true} if this set of conventions can apply.
     */
    @Override
    protected boolean isApplicableTo(final Decoder decoder) {
        final String[] path = decoder.getSearchPath();
        decoder.setSearchPath("Global_attributes");
        final String s = decoder.stringValue("Satellite");
        decoder.setSearchPath(path);                            // Must reset the decoder in its original state.
        return (s != null) && SENTINEL_VALUE.matcher(s).matches();
    }

    /**
     * Specifies a list of groups where to search for named attributes, in preference order.
     * This is used for ISO 19115 metadata. The {@code null} name stands for the root group.
     *
     * @return  name of groups where to search for global attributes, in preference order.
     */
    @Override
    public String[] getSearchPath() {
        return new String[] {"Global_attributes", null, "Processing_attributes"};
    }

    /**
     * Returns the name of an attribute in this convention which is equivalent to the attribute of given name in CF-Convention.
     * The given parameter is a name from <cite>CF conventions</cite> or from <cite>Attribute Convention for Dataset Discovery
     * (ACDD)</cite>.
     *
     * @param  name  an attribute name from CF or ACDD convention.
     * @return the attribute name expected to be found in a netCDF file for GCOM-C, or {@code name} if unknown.
     */
    @Override
    public String mapAttributeName(final String name) {
        return ATTRIBUTES.getOrDefault(name, name);
    }

    /**
     * Returns whether the given variable is used as a coordinate system axis, a coverage or something else.
     *
     * @param  variable  the variable for which to get the role, or {@code null}.
     * @return role of the given variable, or {@code null} if the given variable was null.
     */
    @Override
    public VariableRole roleOf(final Variable variable) {
        VariableRole role = super.roleOf(variable);
        if (role == VariableRole.COVERAGE) {
            /*
             * Exclude (for now) some variables associated to longitude and latitude: Obs_time, Sensor_zenith, Solar_zenith.
             * In a future version we should probably keep them but store them in their own resource aggregate.
             */
            final String group = variable.getGroupName();
            if (GEOMETRY_DATA.equalsIgnoreCase(group)) {
                role = VariableRole.OTHER;
            }
        }
        return role;
    }


    // ┌────────────────────────────────────────────────────────────────────────────────────────────┐
    // │                                      COVERAGE DOMAIN                                       │
    // └────────────────────────────────────────────────────────────────────────────────────────────┘


    /**
     * Returns the attribute-specified name of the dimension at the given index, or {@code null} if unspecified.
     * See {@link Convention#nameOfDimension(Variable, int)} for a more detailed explanation of this information.
     * The implementation in this class fixes a typo found in some {@code "Dim1"} attribute values and generates
     * the values when they are known to be missing.
     *
     * @param  dataOrAxis  the variable for which to get the attribute-specified name of the dimension.
     * @param  index       zero-based index of the dimension for which to get the name.
     * @return dimension name as specified by attributes, or {@code null} if none.
     */
    @Override
    public String nameOfDimension(final Variable dataOrAxis, final int index) {
        String name = super.nameOfDimension(dataOrAxis, index);
        if (name == null) {
            if ("QA_flag".equals(dataOrAxis.getName())) {
                /*
                 * The "QA_flag" variable is missing "Dim0" and "Dim1" attribute in GCOM-C version 1.00.
                 * However not all GCOM-C files use a localization grid. We use the presence of spatial
                 * resolution attribute as a sentinel value for now.
                 */
                if (dataOrAxis.getAttributeType("Spatial_resolution") != null) {
                    switch (index) {
                        case 0: name = "Line grids";  break;
                        case 1: name = "Pixel grids"; break;
                    }
                }
            }
        } else if ("Piexl grids".equalsIgnoreCase(name)) {      // Typo in GCOM-C version 1.00.
            name = "Pixel grids";
        }
        return name;
    }

    /**
     * Returns an enumeration of two-dimensional non-linear transforms that may be tried in attempts to make
     * localization grid more linear.
     *
     * @param  decoder  the netCDF file for which to determine linearizers that may possibly apply.
     * @return enumeration of two-dimensional non-linear transforms to try.
     */
    @Override
    public Set<Linearizer> linearizers(final Decoder decoder) {
        return Collections.singleton(Linearizer.GROUND_TRACK);
    }

    /**
     * Returns the name of nodes (variables or groups) that may define the map projection parameters.
     * For GCOM files, this is {@value #GEOMETRY_DATA}.
     *
     * @param  data  the variable for which to get the grid mapping node.
     * @return name of nodes that may contain the grid mapping, or an empty set if none.
     */
    @Override
    public Set<String> nameOfMappingNode(final Variable data) {
        final Set<String> names = new LinkedHashSet<>(4);
        names.add(GEOMETRY_DATA);
        names.addAll(super.nameOfMappingNode(data));            // Fallback if geometry data does not exist.
        return names;
    }

    /**
     * Returns the map projection definition for the given data variable.
     * This method expects the following attribute names in the {@value #GEOMETRY_DATA} group:
     *
     * {@preformat text
     *     group: Geometry_data {
     *         // group attributes:
     *         string Image_projection      = "EQA (sinusoidal equal area) projection from 0-deg longitude"
     *         float  Upper_left_longitude  = 115.17541
     *         float  Upper_left_latitude   =  80.0
     *         float  Upper_right_longitude = 172.7631
     *         float  Upper_right_latitude  =  80.0
     *         float  Lower_left_longitude  =  58.47609
     *         float  Lower_left_latitude   =  70.0
     *         float  Lower_right_longitude =  87.714134
     *         float  Lower_right_latitude  =  70.0
     *     }
     * }
     *
     * @param  node  the group of variables from which to read attributes.
     * @return the map projection definition as a modifiable map, or {@code null} if none.
     */
    @Override
    public Map<String,Object> projection(final Node node) {
        final String name = node.getAttributeAsString("Image_projection");
        if (name == null) {
            return super.projection(node);
        }
        final String method;
        final int s = name.indexOf(' ');
        final String code = (s >= 0) ? name.substring(0, s) : name;
        if (code.equalsIgnoreCase("EQA")) {
            method = Sinusoidal.NAME;
        } else if (code.equalsIgnoreCase("EQR")) {
            method = Equirectangular.NAME;
        } else if (code.equalsIgnoreCase("PS")) {
            method = PolarStereographicA.NAME;
        } else {
            return super.projection(node);
        }
        final Map<String,Object> definition = new HashMap<>(4);
        definition.put(CF.GRID_MAPPING_NAME, method);
        definition.put(CONVERSION_NAME, name);
        return definition;
    }

    /**
     * The attributes storing values of the 4 corners in degrees with (latitude, longitude) axis order.
     * This is used by {@link #projection(Node)} for inferring a "grid to CRS" transform.
     */
    private static final String[] CORNERS = {
        "Upper_left_latitude",
        "Upper_left_longitude",
        "Upper_right_latitude",
        "Upper_right_longitude",
        "Lower_left_latitude",
        "Lower_left_longitude",
        "Lower_right_latitude",
        "Lower_right_longitude"
    };

    /**
     * Returns the <cite>grid to CRS</cite> transform for the given node.
     * This method is invoked after call to {@link #projection(Node)} resulted in creation of a projected CRS.
     * The {@linkplain ProjectedCRS#getBaseCRS() base CRS} shall have (latitude, longitude) axes in degrees.
     *
     * @param  node       the same node than the one given to {@link #projection(Node)}.
     * @param  baseToCRS  conversion from (latitude, longitude) in degrees to the projected CRS.
     * @return the "grid corner to CRS" transform, or {@code null} if none or unknown.
     * @throws TransformException if a coordinate operation was required but failed.
     */
    @Override
    public MathTransform gridToCRS(final Node node, final MathTransform baseToCRS) throws TransformException {
        final double[] corners = new double[CORNERS.length];
        for (int i=0; i<corners.length; i++) {
            corners[i] = node.getAttributeAsNumber(CORNERS[i]);
        }
        baseToCRS.transform(corners, 0, corners, 0, corners.length / 2);
        /*
         * Compute spans of data (typically in metres) as the average of the spans on both sides
         * (width as length of top and bottom edges, height as length of left and right edges).
         * This code assumes (easting, northing) axes — this is currently not verified.
         */
        double sx = ((corners[2] - corners[0]) + (corners[6] - corners[4])) / 2;
        double sy = ((corners[1] - corners[5]) + (corners[3] - corners[7])) / 2;
        /*
         * Transform the spans into pixel sizes (resolution), then build the transform.
         */
        sx /= (node.getAttributeAsNumber("Number_of_pixels") - 1);
        sy /= (node.getAttributeAsNumber("Number_of_lines")  - 1);
        if (Double.isFinite(sx) && Double.isFinite(sy)) {
            final Matrix3 m = new Matrix3();
            m.m00 =  sx;
            m.m11 = -sy;
            m.m02 = corners[0];
            m.m12 = corners[1];
            return MathTransforms.linear(m);
        }
        return super.gridToCRS(node, baseToCRS);
    }

    /**
     * Returns the default prime meridian, ellipsoid, datum or CRS to use if no information is found in the netCDF file.
     * While GCOM documentation said that the datum is WGS 84, we have found that the map projection applied use spherical
     * formulas.
     *
     * @param  spherical  ignored, since we assume a sphere in all cases.
     * @return information about geodetic objects to use if no explicit information is found in the file.
     */
    @Override
    public CommonCRS defaultHorizontalCRS(final boolean spherical) {
        return CommonCRS.SPHERE;
    }


    // ┌────────────────────────────────────────────────────────────────────────────────────────────┐
    // │                                       COVERAGE RANGE                                       │
    // └────────────────────────────────────────────────────────────────────────────────────────────┘


    /**
     * Returns the range of valid values, or {@code null} if unknown.
     *
     * @param  data  the variable to get valid range of values for.
     * @return the range of valid values, or {@code null} if unknown.
     */
    @Override
    public NumberRange<?> validRange(final Variable data) {
        NumberRange<?> range = super.validRange(data);
        if (range == null) {
            final double min = data.getAttributeAsNumber("Minimum_valid_DN");
            final double max = data.getAttributeAsNumber("Maximum_valid_DN");
            if (Double.isFinite(min) && Double.isFinite(max)) {
                range = NumberRange.createBestFit(min, true, max, true);
            }
        }
        return range;
    }

    /**
     * Returns all no-data values declared for the given variable, or an empty map if none.
     * The map keys are the no-data values (pad sample values or missing sample values).
     * The map values are {@link String} instances containing the description of the no-data value.
     *
     * @param  data  the variable for which to get no-data values.
     * @return no-data values with textual descriptions.
     */
    @Override
    public Map<Number,Object> nodataValues(final Variable data) {
        final Map<Number, Object> pads = super.nodataValues(data);
        for (String name : NO_DATA) {
            final double value = data.getAttributeAsNumber(name);
            if (Double.isFinite(value)) {
                if (name.endsWith(SUFFIX)) {
                    name = name.substring(0, name.length() - SUFFIX.length());
                }
                pads.put(value, name.replace('_', ' '));
            }
        }
        return pads;
    }

    /**
     * Builds the function converting values from their packed formats in the variable to "real" values.
     * This method is invoked only if {@link #validRange(Variable)} returned a non-null value.
     *
     * @param  data  the variable from which to determine the transfer function.
     * @return a transfer function built from the attributes defined in the given variable.
     */
    @Override
    public TransferFunction transferFunction(final Variable data) {
        final TransferFunction tr = super.transferFunction(data);
        if (tr.isIdentity()) {
            final double slope  = data.getAttributeAsNumber("Slope");
            final double offset = data.getAttributeAsNumber("Offset");
            if (Double.isFinite(slope))  tr.setScale (slope);
            if (Double.isFinite(offset)) tr.setOffset(offset);
        }
        return tr;
    }
}
