/*
 * Copyright 2015 Open mHealth
 *
 * Licensed 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.openmhealth.shim.ihealth.mapper;

import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.collect.Lists;
import org.openmhealth.schema.domain.omh.*;
import org.openmhealth.shim.common.mapper.DataPointMapper;

import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

import static com.google.common.base.Preconditions.checkNotNull;
import static java.time.Instant.ofEpochSecond;
import static java.time.OffsetDateTime.ofInstant;
import static org.openmhealth.schema.domain.omh.DataPointModality.SELF_REPORTED;
import static org.openmhealth.schema.domain.omh.DataPointModality.SENSED;
import static org.openmhealth.shim.common.mapper.JsonNodeMappingSupport.*;


/**
 * The base class for mappers that translate iHealth API responses to {@link DataPoint} objects.
 *
 * @author Chris Schaefbauer
 * @author Emerson Farrugia
 */
public abstract class IHealthDataPointMapper<T extends SchemaSupport> implements DataPointMapper<T, JsonNode> {

    public static final String RESOURCE_API_SOURCE_NAME = "iHealth Resource API";
    public static final String DATA_SOURCE_MANUAL = "Manual";
    public static final String DATA_SOURCE_FROM_DEVICE = "FromDevice";

    /**
     * Maps a JSON response with individual data points contained in a JSON array to a list of {@link  DataPoint}
     * objects with the appropriate measure. Splits individual nodes and then iteratively maps the nodes in the list.
     */
    @Override
    public List<DataPoint<T>> asDataPoints(List<JsonNode> responseNodes) {

        // all mapped iHealth responses only require a single endpoint response
        checkNotNull(responseNodes);
        checkNotNull(responseNodes.size() == 1, "A single response node is allowed per call.");

        JsonNode responseNode = responseNodes.get(0);

        Integer measureUnitMagicNumber = null;

        if (getMeasureUnitNodeName().isPresent()) {
            measureUnitMagicNumber = asRequiredInteger(responseNode, getMeasureUnitNodeName().get());
        }

        List<DataPoint<T>> dataPoints = Lists.newArrayList();

        for (JsonNode listEntryNode : asRequiredNode(responseNode, getListNodeName())) {

            asDataPoint(listEntryNode, measureUnitMagicNumber).ifPresent(dataPoints::add);
        }

        return dataPoints;
    }

    /**
     * Creates a data point header with information describing the data point created around the measure.
     * <p>
     * Note: Additional properties within the header come from the iHealth API and are not defined by the data point
     * header schema. Additional properties are subject to change.
     */
    protected DataPointHeader createDataPointHeader(JsonNode listEntryNode, Measure measure) {

        DataPointAcquisitionProvenance.Builder acquisitionProvenanceBuilder =
                new DataPointAcquisitionProvenance.Builder(RESOURCE_API_SOURCE_NAME);

        asOptionalString(listEntryNode, "DataSource").ifPresent(
                dataSource -> setAppropriateModality(dataSource, acquisitionProvenanceBuilder));

        DataPointAcquisitionProvenance acquisitionProvenance = acquisitionProvenanceBuilder.build();

        asOptionalString(listEntryNode, "DataID")
                .ifPresent(externalId -> acquisitionProvenance.setAdditionalProperty("external_id",
                        externalId));

        asOptionalLong(listEntryNode, "LastChangeTime").ifPresent(
                lastUpdatedInUnixSecs -> acquisitionProvenance.setAdditionalProperty("source_updated_date_time",
                        ofInstant(ofEpochSecond(lastUpdatedInUnixSecs), ZoneId.of("Z"))));

        return new DataPointHeader.Builder(UUID.randomUUID().toString(), measure.getSchemaId())
                .setAcquisitionProvenance(acquisitionProvenance)
                .build();
    }

    /**
     * Get an effective time frame based on the measurement date/time information in the list entry node. The effective
     * time frame is set as a single point in time using an OffsetDateTime. This method does not get effective time
     * frame as a time interval.
     *
     * @param listEntryNode A single node from the response result array.
     */
    protected static Optional<TimeFrame> getEffectiveTimeFrameAsDateTime(JsonNode listEntryNode) {

        Optional<Long> epochSecondsInLocalTime = asOptionalLong(listEntryNode, "MDate");

        if (!epochSecondsInLocalTime.isPresent()) {
            return Optional.empty();
        }

        ZoneOffset zoneOffset = null;

        // if the time zone is a JSON string
        if (asOptionalString(listEntryNode, "TimeZone").isPresent() &&
                !asOptionalString(listEntryNode, "TimeZone").get().isEmpty()) {

            zoneOffset = ZoneOffset.of(asOptionalString(listEntryNode, "TimeZone").get());
        }
        // if the time zone is an JSON integer
        else if (asOptionalLong(listEntryNode, "TimeZone").isPresent()) {

            Long timeZoneOffsetValue = asOptionalLong(listEntryNode, "TimeZone").get();

            String timeZoneString = timeZoneOffsetValue.toString();

            // Zone offset cannot parse a positive string offset that's missing a '+' sign (i.e., "0200" vs "+0200")
            if (timeZoneOffsetValue >= 0) {

                timeZoneString = "+" + timeZoneString;
            }

            zoneOffset = ZoneOffset.of(timeZoneString);
        }

        if (zoneOffset == null) {

            return Optional.empty();
        }

        return Optional.of(new TimeFrame(getDateTimeWithCorrectOffset(epochSecondsInLocalTime.get(), zoneOffset)));
    }

    /**
     * This method transforms a timestamp from an iHealth response (which is in the form of local time as epoch seconds)
     * into an {@link OffsetDateTime} with the correct date/time and offset. The timestamps provided in iHealth
     * responses are not unix epoch seconds in UTC but instead a unix epoch seconds value that is offset by the time
     * zone of the data point.
     */
    protected static OffsetDateTime getDateTimeWithCorrectOffset(Long localTimeAsEpochSeconds, ZoneOffset zoneOffset) {

        /*
            iHealth provides the local time of a measurement as if it had occurred in UTC, along with the timezone
            offset where the measurement occurred. To retrieve the correct OffsetDateTime, we must retain the local
            date/time value, but replace the timezone offset.
        */
        return OffsetDateTime.ofInstant(Instant.ofEpochSecond(localTimeAsEpochSeconds), ZoneOffset.UTC)
                .withOffsetSameLocal(zoneOffset);
    }

    /**
     * @param epochSecondsInLocalTime A UNIX epoch timestamp in local time.
     * @param timeZoneString The time zone offset as a String (e.g., "+0200","-2").
     * @return The date time with the correct offset.
     */
    protected static OffsetDateTime getDateTimeAtStartOfDayWithCorrectOffset(
            Long epochSecondsInLocalTime,
            String timeZoneString) {

        // Since the timestamps are in local time, we can use the local date time provided by rendering the timestamp
        // in UTC, then translating that local time to the appropriate offset.
        OffsetDateTime dateTimeFromInstant = ofInstant(ofEpochSecond(epochSecondsInLocalTime), ZoneId.of("Z"));

        return dateTimeFromInstant.toLocalDate().atStartOfDay().atOffset(ZoneOffset.of(timeZoneString));
    }

    /**
     * Gets the user note from a list entry node if that property exists.
     *
     * @param listEntryNode A single entry from the response result array.
     */
    protected static Optional<String> getUserNoteIfExists(JsonNode listEntryNode) {

        Optional<String> note = asOptionalString(listEntryNode, "Note");

        if (note.isPresent() && !note.get().isEmpty()) {

            return note;
        }

        return Optional.empty();
    }

    /**
     * Sets the correct modality based on the iHealth value indicating the source of the data point.
     *
     * @param dataSourceValue The iHealth value in the list entry node indicating the source of the DataPoint.
     * @param builder The DataPointAcquisitionProvenance builder to set the modality.
     */
    private void setAppropriateModality(String dataSourceValue, DataPointAcquisitionProvenance.Builder builder) {

        if (dataSourceValue.equals(DATA_SOURCE_FROM_DEVICE)) {
            builder.setModality(SENSED);
        }
        else if (dataSourceValue.equals(DATA_SOURCE_MANUAL)) {
            builder.setModality(SELF_REPORTED);
        }
    }

    /**
     * @return The name of the JSON array that contains the individual data points. This is different per endpoint.
     */
    protected abstract String getListNodeName();

    /**
     * @return The name of the JSON property whose value indicates the unit of measure used to render the values in the
     * response. This is different per endpoint and some endpoints do not provide any units, in which case, the value
     * should be an empty Optional.
     */
    protected abstract Optional<String> getMeasureUnitNodeName();

    /**
     * @param listEntryNode A single entry from the response result array.
     * @param measureUnitMagicNumber The number representing the units used to render the response, according to
     * iHealth. This is retrieved from the main body of the response node. If the measure type does not use units, then
     * this value is null.
     * @return The data point mapped from the listEntryNode, unless it is skipped.
     */
    protected abstract Optional<DataPoint<T>> asDataPoint(JsonNode listEntryNode, Integer measureUnitMagicNumber);
}
