/*
 * 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.storage.earthobservation;

import java.io.BufferedReader;
import java.io.IOException;
import java.util.Arrays;
import java.util.Date;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.io.LineNumberReader;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.OffsetTime;
import java.time.DateTimeException;
import java.time.temporal.Temporal;

import org.opengis.metadata.Metadata;
import org.opengis.metadata.citation.DateType;
import org.opengis.metadata.identification.TopicCategory;
import org.opengis.metadata.spatial.DimensionNameType;
import org.opengis.metadata.content.CoverageContentType;
import org.opengis.metadata.content.TransferFunctionType;
import org.opengis.metadata.maintenance.ScopeCode;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.referencing.crs.ProjectedCRS;
import org.opengis.util.NoSuchIdentifierException;
import org.opengis.util.FactoryException;

import org.apache.sis.measure.Units;
import org.apache.sis.metadata.iso.DefaultMetadata;
import org.apache.sis.metadata.iso.DefaultIdentifier;
import org.apache.sis.metadata.iso.content.DefaultAttributeGroup;
import org.apache.sis.metadata.iso.content.DefaultBand;
import org.apache.sis.metadata.iso.content.DefaultCoverageDescription;
import org.apache.sis.metadata.sql.MetadataStoreException;
import org.apache.sis.referencing.CRS;
import org.apache.sis.referencing.CommonCRS;
import org.apache.sis.storage.DataStoreException;
import org.apache.sis.storage.DataStoreReferencingException;
import org.apache.sis.storage.event.StoreListeners;
import org.apache.sis.util.Characters;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.resources.Vocabulary;
import org.apache.sis.util.iso.SimpleInternationalString;
import org.apache.sis.internal.referencing.GeodeticObjectBuilder;
import org.apache.sis.internal.referencing.ReferencingFactoryContainer;
import org.apache.sis.internal.referencing.provider.PolarStereographicB;
import org.apache.sis.internal.referencing.provider.TransverseMercator;
import org.apache.sis.internal.storage.MetadataBuilder;
import org.apache.sis.internal.util.StandardDateFormat;
import org.apache.sis.internal.util.Constants;
import org.apache.sis.internal.util.Strings;

import static org.apache.sis.internal.util.CollectionsExt.singletonOrNull;


/**
 * Parses Landsat metadata as {@linkplain DefaultMetadata ISO 19115 Metadata} object.
 * This class reads the content of a given {@link BufferedReader} from buffer position
 * until the first occurrence of the {@code END} keyword. Lines beginning with the
 * {@code #} character (ignoring spaces) are treated as comment lines and ignored.
 *
 * <p>This class will parse properties found in the Landsat metadata file,
 * except {@code GROUP} and {@code END_GROUP}. Example:
 *
 * {@preformat text
 *   DATE_ACQUIRED         = 2014-03-12
 *   SCENE_CENTER_TIME     = 03:02:01.5339408Z
 *   CORNER_UL_LAT_PRODUCT = 12.61111
 *   CORNER_UL_LON_PRODUCT = 108.33624
 *   CORNER_UR_LAT_PRODUCT = 12.62381
 *   CORNER_UR_LON_PRODUCT = 110.44017
 * }
 *
 * <p><b>NOTE FOR MAINTAINER:</b> if the work performed by this class is modified, consider updating
 * <a href="./doc-files/LandsatMetadata.html">./doc-files/LandsatMetadata.html</a> accordingly.</p>
 *
 * @author  Thi Phuong Hao Nguyen (VNSC)
 * @author  Rémi Maréchal (Geomatys)
 * @author  Martin Desruisseaux (Geomatys)
 * @version 1.0
 * @since   0.8
 * @module
 */
final class LandsatReader extends MetadataBuilder {
    /**
     * Names of Landsat bands.
     *
     * @todo Those names and the wavelength could be moved to the {@code SpatialMetadata} database,
     *       as described in <a href="https://issues.apache.org/jira/browse/SIS-338">SIS-338</a>.
     *       It would make easier to enrich the metadata with more information.
     *
     * @see #bands
     * @see #band(String, int)
     */
    private static final String[] BAND_NAMES = {
        "Coastal Aerosol",                      //   433 nm
        "Blue",                                 //   482 nm
        "Green",                                //   562 nm
        "Red",                                  //   655 nm
        "Near-Infrared",                        //   865 nm
        "Short Wavelength Infrared (SWIR) 1",   //  1610 nm
        "Short Wavelength Infrared (SWIR) 2",   //  2200 nm
        "Panchromatic",                         //   590 nm
        "Cirrus",                               //  1375 nm
        "Thermal Infrared Sensor (TIRS) 1",     // 10800 nm
        "Thermal Infrared Sensor (TIRS) 2"      // 12000 nm
    };

    /**
     * Peak response wavelength for the Landsat bands, in nanometres.
     *
     * @see #bands
     * @see #band(String, int)
     */
    private static final short[] WAVELENGTHS = {
        433, 482, 562, 655, 865, 1610, 2200, 590, 1375, 10800, 12000
    };

    /**
     * The pattern determining if the value of {@code ORIGIN} key is of the form
     * “Image courtesy of the U.S. Geological Survey”.
     */
    static final Pattern CREDIT = Pattern.compile("\\bcourtesy\\h+of\\h+(the)?\\b\\s*", Pattern.CASE_INSENSITIVE);

    /**
     * Number of spatial dimensions. This is the number of coordinate values to be stored
     * in the {@link #gridSizes} and {@link #corners} arrays for each tuple.
     */
    static final int DIM = 2;

    /**
     * The {@value} suffix added to attribute names that are followed by a band number.
     * This band suffix is itself followed by the {@code '_'} character, then the band number.
     * Example: {@code "REFLECTANCE_ADD_BAND_1"}.
     */
    private static final String BAND_SUFFIX = "_BAND";

    /**
     * A bit mask of the group in which to classify a given band.
     * There is three groups: panchromatic, reflective or thermal bands:
     *
     * <ul>
     *   <li><b>Panchromatic:</b> band  8.</li>
     *   <li><b>Reflective:</b>   bands 1, 2, 3, 4, 5, 6, 7, 9.</li>
     *   <li><b>Thermal:</b>      bands 10, 11.</li>
     * </ul>
     *
     * For a band numbered from 1 to 11 inclusive, the group is computed by
     * (constants 2 and 3 in that formula depends on the {@link #NUM_GROUPS} value):
     *
     * {@preformat java
     *   group = (BAND_GROUPS >>> 2*(band - 1)) & 3;
     * }
     *
     * The result is one of the {@link #PANCHROMATIC}, {@link #REFLECTIVE} or {@link #THERMAL} constant values
     * divided by {@value #DIM}.
     */
    static final int BAND_GROUPS = 2692437;     // Value computed by LandsatReaderTest.verifyBandGroupsMask()

    /**
     * Maximum number of band groups that a metadata may contains.
     * See {@link #BAND_GROUPS} javadoc for the list of groups.
     */
    private static final int NUM_GROUPS = 3;

    /**
     * Index of panchromatic, reflective or thermal groups in the {@link #gridSizes} array.
     * The image size is each group is given by {@value #DIM} integers: the width and the height.
     */
    static final int PANCHROMATIC = 0*DIM,
                     REFLECTIVE   = 1*DIM,
                     THERMAL      = 2*DIM;

    /**
     * Index of projected and geographic coordinates in the {@link #corners} array.
     * Each kind of coordinates are stored as 4 corners of {@value #DIM} coordinate values.
     */
    private static final int PROJECTED  = 0*DIM,
                             GEOGRAPHIC = 4*DIM;

    /**
     * The keyword for end of metadata file.
     */
    private static final String END = "END";

    /**
     * An identifier of the file being read, or {@code null} if unknown.
     * This is used mostly for formatting error messages.
     *
     * @see #getFilename()
     */
    private String filename;

    /**
     * Where to send the warnings.
     */
    private final StoreListeners listeners;

    /**
     * Group in process of being parsed, or {@code null} if none.
     */
    private String group;

    /**
     * The acquisition time, or {@code null} if not yet known. This needs to be parsed in two steps:
     * first by parsing the {@code "DATE_ACQUIRED"} attribute, then {@code "SCENE_CENTER_TIME"}.
     *
     * @see #flushSceneTime()
     */
    private Temporal sceneTime;

    /**
     * Projected and geographic coordinate values, stocked temporarily before to be saved in the extent.
     * Values are in (<var>x</var>,<var>y</var>) or (<var>lon</var>,<var>lat</var>) order.
     * The first 8 values are the projected ones. The next 8 values are the geographic ones.
     * Corner order is UL, UR, LL, LR.
     */
    private final double[] corners;

    /**
     * Image width and hight in pixels, as unsigned integers. Values are (<var>width</var>,<var>height</var>) tuples.
     * Tuples in this array are for {@link #PANCHROMATIC}, {@link #REFLECTIVE} or {@link #THERMAL} bands, in that order.
     */
    private final int[] gridSizes;

    /**
     * The bands description. Any element can be null if the corresponding band is not defined.
     * The bands can be, in this exact order:
     *
     * <ol>
     *   <li>Coastal Aerosol</li>
     *   <li>Blue</li>
     *   <li>Green</li>
     *   <li>Red</li>
     *   <li>Near-Infrared</li>
     *   <li>Short Wavelength Infrared (SWIR) 1</li>
     *   <li>Short Wavelength Infrared (SWIR) 2</li>
     *   <li>Panchromatic</li>
     *   <li>Cirrus</li>
     *   <li>Thermal Infrared Sensor (TIRS) 1</li>
     *   <li>Thermal Infrared Sensor (TIRS) 2</li>
     * </ol>
     *
     * @see #BAND_NAMES
     * @see #WAVELENGTHS
     * @see #band(String, int)
     */
    private final DefaultBand[] bands;

    /**
     * The enumeration for the {@code "DATUM"} element, to be used for creating the Coordinate Reference System.
     */
    private CommonCRS datum;

    /**
     * The Universal Transverse Mercator (UTM) zone as a number from 1 to 60 inclusive, or 0 if the zone has not
     * yet been determined. If the parser determined that the projection is Polar Stereographic, then this field
     * is set to -1.
     */
    private short utmZone;

    /**
     * The map projection parameters. This is used only for the polar stereographic case.
     */
    private ParameterValueGroup projection;

    /**
     * The referencing objects factories.
     */
    private final ReferencingFactoryContainer factories;

    /**
     * Creates a new metadata parser.
     *
     * @param  filename   an identifier of the file being read, or {@code null} if unknown.
     * @param  listeners  where to sent warnings that may occur during the parsing process.
     */
    LandsatReader(final String filename, final StoreListeners listeners) {
        this.filename  = filename;
        this.listeners = listeners;
        this.factories = new ReferencingFactoryContainer();
        this.bands     = new DefaultBand[BAND_NAMES.length];
        this.gridSizes = new int[NUM_GROUPS * DIM];
        this.corners   = new double[GEOGRAPHIC + (4*DIM)];      // GEOGRAPHIC is the last group of corners to store.
        Arrays.fill(corners, Double.NaN);
    }

    /**
     * Parses the metadata from the given characters reader.
     * The parsing stop after the first {@code "END"} keyword.
     * See class javadoc for more information on the expected format.
     *
     * @param  reader  a reader opened on the Landsat file.
     *         It is caller's responsibility to close this reader.
     * @throws IOException if an I/O error occurred while reading the given stream.
     * @throws DataStoreException if the content is not a Landsat file.
     */
    void read(final BufferedReader reader) throws IOException, DataStoreException {
        newCoverage(true);              // Starts the description of a new image.
        String line;
        while ((line = reader.readLine()) != null) {
            int end  = CharSequences.skipTrailingWhitespaces(line, 0, line.length());
            int start = CharSequences.skipLeadingWhitespaces(line, 0, end);
            if (start < end && line.charAt(start) != '#') {
                /*
                 * Separate the line into its key and value. For example in CORNER_UL_LAT_PRODUCT = 12.61111,
                 * the key will be CORNER_UL_LAT_PRODUCT and the value will be 12.61111.
                 */
                final int separator = line.indexOf('=', start);
                if (separator < 0) {
                    /*
                     * Landsat metadata ends with the END keyword, without value after that keyword.
                     * If we find it, stop reading. All remaining lines (if any) will be ignored.
                     */
                    if (end - start != END.length() || !line.regionMatches(true, start, END, 0, END.length())) {
                        throw new DataStoreException(errors().getString(Errors.Keys.NotAKeyValuePair_1, line));
                    }
                    return;
                }
                /*
                 * If the key ends with "_BAND_" followed by a number, remove the band number from the
                 * key and parse that number as an integer value. Exemple: "REFLECTANCE_ADD_BAND_1".
                 * We keep the "_BAND_" suffix in the key for avoiding ambiguity.
                 */
                String key = line.substring(start, CharSequences.skipTrailingWhitespaces(line, start, separator)).toUpperCase(Locale.US);
                int band = 0;
                for (int i=key.length(); --i >= 0;) {
                    final char c = key.charAt(i);
                    if (c < '0' || c > '9') {
                        if (c == '_') {
                            if (key.regionMatches(i - BAND_SUFFIX.length(), BAND_SUFFIX, 0, BAND_SUFFIX.length())) try {
                                band = Integer.parseInt(key.substring(++i));
                                key = key.substring(0, i);
                            } catch (NumberFormatException e) {
                                warning(key, reader, e);
                            }
                        }
                        break;
                    }
                }
                /*
                 * In a Landsat file, String values are between quotes. Example: STATION_ID = "LGN".
                 * If such quotes are found, remove them.
                 */
                start = CharSequences.skipLeadingWhitespaces(line, separator + 1, end);
                if (end - start >= 2 && line.charAt(start) == '"' && line.charAt(end - 1) == '"') {
                    start = CharSequences.skipLeadingWhitespaces(line, start + 1, --end);
                    end = CharSequences.skipTrailingWhitespaces(line, start, end);
                }
                try {
                    parseKeyValuePair(key, band, line.substring(start, end));
                } catch (IllegalArgumentException | DateTimeException e) {
                    warning(key, reader, e);
                }
            }
        }
        listeners.warning(errors().getString(Errors.Keys.UnexpectedEndOfFile_1, getFilename()));
    }

    /**
     * Parses the given string as a {@code double} value, returning a shared instance if possible.
     *
     * @param   value  the string value to parse.
     * @return  the parsed value.
     * @throws  NumberFormatException if the given value can not be parsed.
     */
    private Double parseDouble(final String value) throws NumberFormatException {
        return shared(Double.valueOf(value));
    }

    /**
     * Parses the given value and stores it at the given index in the {@link #corners} array.
     * The given index must be one of the {@link #PROJECTED} or {@link #GEOGRAPHIC} constants
     * plus the coordinate index.
     */
    private void parseCorner(final int index, final String value) throws NumberFormatException {
        corners[index] = Double.parseDouble(value);
    }

    /**
     * Parses the given value and stores it at the given index in the {@link #gridSizes} array.
     *
     * @param  index  {@link #PANCHROMATIC}, {@link #REFLECTIVE} or {@link #THERMAL},
     *                +1 if parsing the height instead than the width.
     * @param  value  the value to parse.
     */
    private void parseGridSize(final int index, final String value) throws NumberFormatException {
        gridSizes[index] = Integer.parseUnsignedInt(value);
    }

    /**
     * Invoked for every key-value pairs found in the file.
     * Leading and trailing spaces, if any, have been removed.
     *
     * @param  key    the key in upper cases.
     * @param  band   the band number, or 0 if none.
     * @param  value  the value, without quotes if those quotes existed.
     * @throws NumberFormatException if the value was expected to be a string but the parsing failed.
     * @throws DateTimeException if the value was expected to be a date but the parsing failed,
     *         or if the result of the parsing was not of the expected type.
     * @throws IllegalArgumentException if the value is out of range.
     */
    private void parseKeyValuePair(final String key, final int band, String value)
            throws IllegalArgumentException, DateTimeException, DataStoreException
    {
        switch (key) {
            case "GROUP": {
                group = value;
                break;
            }
            case "END_GROUP": {
                group = null;
                break;
            }

            ////
            //// GROUP = METADATA_FILE_INFO
            ////

            /*
             * Origin of the product.
             * Value is "Image courtesy of the U.S. Geological Survey".
             */
            case "ORIGIN": {
                final Matcher m = CREDIT.matcher(value);
                if (m.find()) {
                    newParty(MetadataBuilder.PartyType.ORGANISATION);
                    addAuthor(value.substring(m.end()));
                }
                addCredits(value);
                break;
            }
            /*
             * Product Request ID. NNNNNNNNNNNNN_UUUUU, where NNNNNNNNNNNNN = 13-digit Tracking,
             * Routing, and Metrics (TRAM) order number and UUUUU = 5-digit TRAM unit number.
             * Example: "0501403126384_00011"
             */
            case "REQUEST_ID": {
                addAcquisitionRequirement(null, value);
                break;
            }
            /*
             * The unique Landsat scene identifier.
             * Format is {@code Ls8ppprrrYYYYDDDGGGVV}.
             * Example: "LC81230522014071LGN00".
             */
            case "LANDSAT_SCENE_ID": {
                addTitleOrIdentifier(value, MetadataBuilder.Scope.ALL);
                break;
            }
            /*
             * The date when the metadata file for the L1G product set was created.
             * The date is based on Universal Time Coordinated (UTC).
             * Date format is {@code YYYY-MM-DDTHH:MM:SSZ}.
             * Example: "2014-03-12T06:06:35Z".
             */
            case "FILE_DATE": {
                addCitationDate(StandardDateFormat.toDate(OffsetDateTime.parse(value)),
                                DateType.CREATION, MetadataBuilder.Scope.ALL);
                break;
            }
            /*
             * The Ground Station that received the data. Grounds station identifiers are specified in LSDS-547.
             * Example: "LGN" = Landsat Ground Network.
             */
// TODO     case "STATION_ID":
            /*
             * The processing software version that created the product. Can be "IAS_X.Y.Z" or "LPGS_X.Y.Z"
             * where X, Y and Z are major, minor and patch version numbers.
             * Example: "LPGS_2.3.0".
             */
// TODO     case "PROCESSING_SOFTWARE_VERSION":

            ////
            //// GROUP = PRODUCT_METADATA
            ////

            /*
             * The identifier to inform the user of the product type.
             * Value can be "L1T" or "L1GT".
             */
            case "DATA_TYPE": {
                setProcessingLevelCode("Landsat", value);
                break;
            }
            /*
             * Indicates the source of the DEM used in the correction process.
             * Value can be "GLS2000", "RAMP" or "GTOPO30".
             */
            case "ELEVATION_SOURCE": {
                addSource(value, ScopeCode.MODEL, Vocabulary.formatInternational(Vocabulary.Keys.DigitalElevationModel));
                break;
            }
            /*
             * The output format of the image.
             * Value is "GEOTIFF".
             */
            case "OUTPUT_FORMAT": {
                if (Constants.GEOTIFF.equalsIgnoreCase(value)) try {
                    value = Constants.GEOTIFF;              // Because 'metadata.setFormat(…)' is case-sensitive.
                    setFormat(value);
                    break;
                } catch (MetadataStoreException e) {
                    warning(key, null, e);
                }
                addFormatName(value);
                break;
            }
            /*
             * Spacecraft from which the data were captured.
             * Example: "LANDSAT_8".
             */
            case "SPACECRAFT_ID": {
                addPlatform(null, value);
                break;
            }
            /*
             * Sensor(s) used to capture this scene.
             * Example: "OLI", "TIRS" or "OLI_TIRS".
             */
            case "SENSOR_ID": {
                addInstrument(null, value);
                break;
            }
            /*
             * The date the image was acquired.
             * Date format is {@code YYYY-MM-DD}.
             * Example: "2014-03-12".
             */
            case "DATE_ACQUIRED": {
                final LocalDate date = LocalDate.parse(value);
                if (sceneTime instanceof OffsetTime) {
                    sceneTime = date.atTime((OffsetTime) sceneTime);
                } else if (!date.equals(sceneTime)) {
                    flushSceneTime();
                    sceneTime = date;
                }
                break;
            }
            /*
             * Scene center time of the date the image was acquired.
             * Time format is {@code HH:MI:SS.SSSSSSSZ}.
             * Example: "03:02:01.5339408Z".
             */
            case "SCENE_CENTER_TIME": {
                final OffsetTime time = OffsetTime.parse(value);
                if (sceneTime instanceof LocalDate) {
                    sceneTime = ((LocalDate) sceneTime).atTime(time);
                } else {
                    sceneTime = time;
                }
                break;
            }
            /*
             * The longitude and latitude values for the upper-left (UL), upper-right (UR), lower-left (LL)
             * and lower-right (LR) corners of the product, measured at the center of the pixel.
             * Positive longitude value indicates east longitude; negative value indicates west longitude.
             * Positive latitude value indicates north latitude; negative value indicates south latitude.
             * Units are in degrees.
             */
            case "CORNER_UL_LON_PRODUCT": parseCorner(GEOGRAPHIC + 0, value); break;
            case "CORNER_UL_LAT_PRODUCT": parseCorner(GEOGRAPHIC + 1, value); break;
            case "CORNER_UR_LON_PRODUCT": parseCorner(GEOGRAPHIC + 2, value); break;
            case "CORNER_UR_LAT_PRODUCT": parseCorner(GEOGRAPHIC + 3, value); break;
            case "CORNER_LL_LON_PRODUCT": parseCorner(GEOGRAPHIC + 4, value); break;
            case "CORNER_LL_LAT_PRODUCT": parseCorner(GEOGRAPHIC + 5, value); break;
            case "CORNER_LR_LON_PRODUCT": parseCorner(GEOGRAPHIC + 6, value); break;
            case "CORNER_LR_LAT_PRODUCT": parseCorner(GEOGRAPHIC + 7, value); break;
            /*
             * The upper-left (UL), upper-right (UR), lower-left (LL) and lower-right (LR) corner map
             * projection X and Y coordinate, measured at the center of the pixel. Units are in meters.
             */
            case "CORNER_UL_PROJECTION_X_PRODUCT": parseCorner(PROJECTED + 0, value); break;
            case "CORNER_UL_PROJECTION_Y_PRODUCT": parseCorner(PROJECTED + 1, value); break;
            case "CORNER_UR_PROJECTION_X_PRODUCT": parseCorner(PROJECTED + 2, value); break;
            case "CORNER_UR_PROJECTION_Y_PRODUCT": parseCorner(PROJECTED + 3, value); break;
            case "CORNER_LL_PROJECTION_X_PRODUCT": parseCorner(PROJECTED + 4, value); break;
            case "CORNER_LL_PROJECTION_Y_PRODUCT": parseCorner(PROJECTED + 5, value); break;
            case "CORNER_LR_PROJECTION_X_PRODUCT": parseCorner(PROJECTED + 6, value); break;
            case "CORNER_LR_PROJECTION_Y_PRODUCT": parseCorner(PROJECTED + 7, value); break;
            /*
             * The number of product lines and samples for the panchromatic, reflective and thermal bands.
             * Those parameters are only present if the corresponding band is present in the product.
             */
            case "PANCHROMATIC_LINES":   parseGridSize(PANCHROMATIC + 1, value); break;
            case "PANCHROMATIC_SAMPLES": parseGridSize(PANCHROMATIC,     value); break;
            case "REFLECTIVE_LINES":     parseGridSize(REFLECTIVE + 1,   value); break;
            case "REFLECTIVE_SAMPLES":   parseGridSize(REFLECTIVE,       value); break;
            case "THERMAL_LINES":        parseGridSize(THERMAL + 1,      value); break;
            case "THERMAL_SAMPLES":      parseGridSize(THERMAL,          value); break;
            /*
             * The grid cell size in meters used in creating the image for the band, if part of the product.
             * This parameter is only included if the corresponding band is included in the product.
             */
            case "GRID_CELL_SIZE_PANCHROMATIC":
            case "GRID_CELL_SIZE_REFLECTIVE":
            case "GRID_CELL_SIZE_THERMAL": {
                addResolution(Double.parseDouble(value));
                break;
            }
            /*
             * The file name of the TIFF image that contains the pixel values for a band.
             * This parameter is only present if the band is included in the product.
             */
            case "FILE_NAME_BAND_": {
                final DefaultBand db = band(key, band);
                if (db != null) {
                    db.getNames().add(new DefaultIdentifier(value));
                }
                break;
            }
            /*
             * The file name for L1 metadata.
             * Exemple: "LC81230522014071LGN00_MTL.txt".
             */
            case "METADATA_FILE_NAME": {
                if (filename == null) {
                    filename = value;
                }
                break;
            }

            ////
            //// GROUP = IMAGE_ATTRIBUTES
            ////

            /*
             * The overall cloud coverage (percent) of the WRS-2 scene as a value between 0 and 100 inclusive.
             * -1 indicates that the score was not calculated.
             */
            case "CLOUD_COVER": {
                final double v = Double.parseDouble(value);
                if (v >= 0) setCloudCoverPercentage(v);
                break;
            }
            /*
             * The Sun azimuth angle in degrees for the image center location at the image center acquisition time.
             * Values are from -180 to 180 degrees inclusive.
             * A positive value indicates angles to the east or clockwise from the north.
             * A negative value indicates angles to the west or counterclockwise from the north.
             */
            case "SUN_AZIMUTH": {
                setIlluminationAzimuthAngle(Double.parseDouble(value));
                break;
            }
            /*
             * The Sun elevation angle in degrees for the image center location at the image center acquisition time.
             * Values are from -90 to 90 degrees inclusive.
             * A positive value indicates a daytime scene. A negative value indicates a nighttime scene.
             * Note: for reflectance calculation, the sun zenith angle is needed, which is 90 - sun elevation angle.
             */
            case "SUN_ELEVATION": {
                setIlluminationElevationAngle(Double.parseDouble(value));
                break;
            }

            ////
            //// GROUP = MIN_MAX_PIXEL_VALUE
            ////

            /*
             * Minimum achievable spectral radiance value for a band 1.
             * This parameter is only present if this band is included in the product.
             */
            case "QUANTIZE_CAL_MIN_BAND_": {
                final Double v = parseDouble(value);        // Done first in case an exception is thrown.
                final DefaultBand db = band(key, band);
                if (db != null) {
                    db.setMinValue(v);
                }
                break;
            }
            /*
             * Maximum achievable spectral radiance value for a band 1.
             * This parameter is only present if this band is included in the product.
             */
            case "QUANTIZE_CAL_MAX_BAND_": {
                final Double v = parseDouble(value);        // Done first in case an exception is thrown.
                final DefaultBand db = band(key, band);
                if (db != null) {
                    db.setMaxValue(v);
                }
                break;
            }

            ////
            //// GROUP = RADIOMETRIC_RESCALING
            ////

            /*
             * The multiplicative rescaling factor used to convert calibrated DN to Radiance units for a band.
             * Unit is W/(m² sr um)/DN.
             */
            case "RADIANCE_MULT_BAND_": {
                setTransferFunction(key, band, true, value);
                break;
            }
            /*
             * The additive rescaling factor used to convert calibrated DN to Radiance units for a band.
             * Unit is W/(m² sr um)/DN.
             */
            case "RADIANCE_ADD_BAND_": {
                setTransferFunction(key, band, false, value);
                break;
            }

            ////
            //// GROUP = PROJECTION_PARAMETERS
            ////

            /*
             * The map projection used in creating the image.
             * Universal Transverse Mercator (UTM) or Polar Stereographic (PS).
             */
            case "MAP_PROJECTION": {
                if ("UTM".equalsIgnoreCase(value)) {
                    projection = null;
                } else if ("PS".equalsIgnoreCase(value)) try {
                    projection = factories.getMathTransformFactory()
                                    .getDefaultParameters(Constants.EPSG + ':' + PolarStereographicB.IDENTIFIER);
                    utmZone = -1;
                } catch (NoSuchIdentifierException e) {
                    // Should never happen with Apache SIS implementation of MathTransformFactory.
                    throw new DataStoreReferencingException(e);
                }
                break;
            }
            /*
             * The datum used in creating the image. This is usually "WGS84".
             * We ignore the "ELLIPSOID" attribute because it is implied by the datum.
             */
            case "DATUM": {
                datum = CommonCRS.valueOf(Strings.toUpperCase(value, Characters.Filter.LETTERS_AND_DIGITS));
                break;
            }
            /*
             * The value used to indicate the zone number. This parameter is only included for the UTM projection.
             * If this parameter is defined more than once (which should be illegal), only the first occurrence is
             * retained. If the projection is polar stereographic, the parameter is ignored.
             */
            case "UTM_ZONE": {
                if (utmZone == 0) {
                    utmZone = Short.parseShort(value);
                }
                break;
            }
            /*
             * Polar Stereographic projection parameters. Most parameters do not vary, except the latitude of
             * true scale which is -71 for scenes over Antarctica and 71 for off-nadir scenes at the North Pole.
             * If the datum is WGS84, then this is equivalent to EPSG:3031 and EPSG:3995 respectively.
             */
            case "VERTICAL_LON_FROM_POLE": setProjectionParameter(key, Constants.CENTRAL_MERIDIAN,    value, false); break;
            case "TRUE_SCALE_LAT":         setProjectionParameter(key, Constants.STANDARD_PARALLEL_1, value, false); break;
            case "FALSE_EASTING":          setProjectionParameter(key, Constants.FALSE_EASTING,       value, true);  break;
            case "FALSE_NORTHING":         setProjectionParameter(key, Constants.FALSE_NORTHING,      value, true);  break;
        }
    }

    /**
     * Sets a component of the linear transfer function.
     *
     * @param  key      the key without its band number. Used only for formatting warning messages.
     * @param  band     index of the band to set.
     * @param  isScale  {@code true} for setting the scale factor, or {@code false} for setting the offset.
     * @param  value    the value to set.
     */
    private void setTransferFunction(final String key, final int band, final boolean isScale, final String value) {
        final Double v = parseDouble(value);            // Done first in case an exception is thrown.
        final DefaultBand db = band(key, band);
        if (db != null) {
            db.setTransferFunctionType(TransferFunctionType.LINEAR);
            if (isScale) {
                db.setScaleFactor(v);
            } else {
                db.setOffset(v);
            }
        }
    }

    /**
     * Returns the band at the given index, creating it if needed.
     * If the given index is out of range, then this method logs a warning and returns {@code null}.
     *
     * @param  key    the key without its band number. Used only for formatting warning messages.
     * @param  index  the band index.
     */
    private DefaultBand band(final String key, int index) {
        if (index < 1 || index > BAND_NAMES.length) {
            listeners.warning(errors().getString(Errors.Keys.UnexpectedValueInElement_2, key + index, index));
            return null;
        }
        DefaultBand band = bands[--index];
        if (band == null) {
            band = new DefaultBand();
            band.setDescription(new SimpleInternationalString(BAND_NAMES[index]));
            band.setPeakResponse((double) WAVELENGTHS[index]);
            band.setBoundUnits(Units.NANOMETRE);
            bands[index] = band;
        }
        return band;
    }

    /**
     * Sets a map projection parameter. The parameter is ignored if the projection has not been set.
     *
     * @param key       the Landsat key, for formatting error message if needed.
     * @param name      the projection parameter name.
     * @param value     the parameter value.
     * @param isLinear  {@code true} for value in metres, or {@code false} for value in degrees.
     */
    private void setProjectionParameter(final String key, final String name, final String value, final boolean isLinear) {
        if (projection != null) {
            projection.parameter(name).setValue(Double.parseDouble(value), isLinear ? Units.METRE : Units.DEGREE);
        } else {
            listeners.warning(errors().getString(Errors.Keys.UnexpectedProperty_2, filename, key));
        }
    }

    /**
     * Writes the value of {@link #sceneTime} into the metadata object as a temporal extent.
     *
     * @throws DateTimeException if {@link #sceneTime} is an instance of {@link OffsetTime}. This may
     *         happen if {@code SCENE_CENTER_TIME} attribute was found without {@code DATE_ACQUIRED}.
     */
    private void flushSceneTime() {
        final Temporal st = sceneTime;
        if (st != null) {
            sceneTime = null;                   // Clear now in case an exception it thrown below.
            final Date t = StandardDateFormat.toDate(st);
            addAcquisitionTime(t);
            try {
                addTemporalExtent(t, t);
            } catch (UnsupportedOperationException e) {
                // May happen if the temporal module (which is optional) is not on the classpath.
                warning(null, null, e);
            }
        }
    }

    /**
     * Computes the bounding box for the 8 {@link #corners} values starting at the given index.
     * Valid indices are 0 for the projected envelope or 8 for the geographic bounding box.
     * Result is stored in the 4 values starting the given {@code base} index.
     *
     * @return {@code true} of success, or {@code false} if there is no bounding box.
     */
    private boolean toBoundingBox(int base) {
        double xmin = Double.POSITIVE_INFINITY;
        double ymin = Double.POSITIVE_INFINITY;
        double xmax = Double.NEGATIVE_INFINITY;
        double ymax = Double.NEGATIVE_INFINITY;
        for (int i = base + (4*DIM); --i >= base;) {
            double v = corners[i];
            if (v < ymin) ymin = v;
            if (v > ymax) ymax = v;
            v = corners[--i];
            if (v < xmin) xmin = v;
            if (v > xmax) xmax = v;
        }
        if (xmin < xmax && ymin < ymax) {
            corners[  base] = xmin;
            corners[++base] = xmax;
            corners[++base] = ymin;
            corners[++base] = ymax;
            return true;
        }
        return false;
    }

    /**
     * Returns the metadata about the resources described in the Landsat file.
     * The {@link #read(BufferedReader)} method must be invoked at least once before.
     *
     * @throws FactoryException if an error occurred while creating the Coordinate Reference System.
     */
    final Metadata getMetadata() throws FactoryException {
        addLanguage(Locale.ENGLISH, MetadataBuilder.Scope.METADATA);
        addResourceScope(ScopeCode.valueOf("COVERAGE"), null);
        addTopicCategory(TopicCategory.GEOSCIENTIFIC_INFORMATION);
        try {
            flushSceneTime();
        } catch (DateTimeException e) {
            // May happen if the SCENE_CENTER_TIME attribute was found without DATE_ACQUIRED.
            warning(null, null, e);
        }
        /*
         * Create the Coordinate Reference System. We normally have only one of UTM or Polar Stereographic,
         * but this block is nevertheless capable to take both (such metadata are likely to be invalid, but
         * we can not guess which one of the two CRS is correct).
         */
        if (datum != null) {
            if (utmZone > 0) {
                addReferenceSystem(datum.universal(1, TransverseMercator.Zoner.UTM.centralMeridian(utmZone)));
            }
            if (projection != null) {
                final double sp = projection.parameter(Constants.STANDARD_PARALLEL_1).doubleValue();
                ProjectedCRS crs = (ProjectedCRS) CRS.forCode(Constants.EPSG + ":" +
                        (sp >= 0 ? Constants.EPSG_ARCTIC_POLAR_STEREOGRAPHIC         // Standard parallel = 71°N
                                 : Constants.EPSG_ANTARCTIC_POLAR_STEREOGRAPHIC));   // Standard parallel = 71°S
                if (datum != CommonCRS.WGS84 || Math.abs(sp) != 71
                        || projection.parameter(Constants.FALSE_EASTING)   .doubleValue() != 0
                        || projection.parameter(Constants.FALSE_NORTHING)  .doubleValue() != 0
                        || projection.parameter(Constants.CENTRAL_MERIDIAN).doubleValue() != 0)
                {
                    crs = new GeodeticObjectBuilder(listeners.getLocale())
                            .addName("Polar stereographic").setConversion(projection)
                            .createProjectedCRS(datum.geographic(), crs.getCoordinateSystem());
                }
                addReferenceSystem(crs);
            }
        }
        /*
         * Set information about envelope (or geographic area) and grid size.
         */
        if (toBoundingBox(GEOGRAPHIC)) {
            addExtent(corners, GEOGRAPHIC);
        }
        for (int i = 0; i < gridSizes.length; i += DIM) {
            final int width  = gridSizes[i  ];
            final int height = gridSizes[i+1];
            if ((width | height) != 0) {
                newGridRepresentation(MetadataBuilder.GridType.GEORECTIFIED);
                setAxisName(0, DimensionNameType.SAMPLE);
                setAxisName(1, DimensionNameType.LINE);
                setAxisSize(0, Integer.toUnsignedLong(width));
                setAxisSize(1, Integer.toUnsignedLong(height));
            }
        }
        /*
         * At this point we are done configuring he metadata builder. Creates the ISO 19115 metadata instance,
         * then continue adding some more specific metadata elements by ourself. For example information about
         * bands are splitted in 3 different AttributeGroups based on their grid size.
         */
        setISOStandards(true);
        final DefaultMetadata result = build(false);
        if (result != null) {
            /*
             * Set information about all non-null bands. The bands are categorized in three groups:
             * PANCHROMATIC, REFLECTIVE and THERMAL. The group in which each band belong is encoded
             * in the BAND_GROUPS bitmask.
             */
            final DefaultCoverageDescription content = (DefaultCoverageDescription) singletonOrNull(result.getContentInfo());
            if (content != null) {
                final DefaultAttributeGroup[] groups = new DefaultAttributeGroup[NUM_GROUPS];
                for (int i=0; i < bands.length; i++) {
                    final DefaultBand band = bands[i];
                    if (band != null) {
                        final int gi = (BAND_GROUPS >>> 2*i) & 3;
                        DefaultAttributeGroup group = groups[gi];
                        if (group == null) {
                            group = new DefaultAttributeGroup(CoverageContentType.PHYSICAL_MEASUREMENT, null);
                            content.getAttributeGroups().add(group);
                            groups[gi] = group;
                        }
                        group.getAttributes().add(band);
                    }
                }
            }
            result.transitionTo(DefaultMetadata.State.FINAL);
        }
        return result;
    }

    /**
     * Returns the filename to show in error messages, or a localized "unnamed" word if none.
     */
    private String getFilename() {
        return (filename != null) ? filename : Vocabulary.getResources(listeners.getLocale()).getString(Vocabulary.Keys.Unnamed);
    }

    /**
     * Prepends the group name before the given key, if a group name exists.
     * This is used only for formatting warning messages.
     */
    private String toLongName(String key) {
        if (group != null) {
            key = group + ':' + key;
        }
        return key;
    }

    /**
     * Invoked when a non-fatal exception occurred while reading metadata. This method
     * sends a record to the registered listeners if any, or logs the record otherwise.
     */
    private void warning(String key, final BufferedReader reader, final Exception e) {
        if (key != null) {
            String file = getFilename();
            if (reader instanceof LineNumberReader) {
                file = file + ":" + ((LineNumberReader) reader).getLineNumber();
            }
            key = errors().getString(Errors.Keys.CanNotReadPropertyInFile_2, toLongName(key), file);
        }
        listeners.warning(key, e);
    }

    /**
     * Returns the resources to use for formatting error messages.
     */
    private Errors errors() {
        return Errors.getResources(listeners.getLocale());
    }
}
