/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * 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 android.util;

import android.os.Parcel;
import android.os.Parcelable;

import com.android.internal.annotations.VisibleForTesting;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ProtocolException;
import java.time.Clock;
import java.time.LocalTime;
import java.time.Period;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Iterator;
import java.util.Objects;

/**
 * Description of an event that should recur over time at a specific interval
 * between two anchor points in time.
 *
 * @hide
 */
public class RecurrenceRule implements Parcelable {
    private static final String TAG = "RecurrenceRule";
    private static final boolean DEBUG = true;

    private static final int VERSION_INIT = 0;

    /** {@hide} */
    @VisibleForTesting
    public static Clock sClock = Clock.systemDefaultZone();

    public final ZonedDateTime start;
    public final ZonedDateTime end;
    public final Period period;

    public RecurrenceRule(ZonedDateTime start, ZonedDateTime end, Period period) {
        this.start = start;
        this.end = end;
        this.period = period;
    }

    @Deprecated
    public static RecurrenceRule buildNever() {
        return new RecurrenceRule(null, null, null);
    }

    @Deprecated
    public static RecurrenceRule buildRecurringMonthly(int dayOfMonth, ZoneId zone) {
        // Assume we started last January, since it has all possible days
        final ZonedDateTime now = ZonedDateTime.now(sClock).withZoneSameInstant(zone);
        final ZonedDateTime start = ZonedDateTime.of(
                now.toLocalDate().minusYears(1).withMonth(1).withDayOfMonth(dayOfMonth),
                LocalTime.MIDNIGHT, zone);
        return new RecurrenceRule(start, null, Period.ofMonths(1));
    }

    private RecurrenceRule(Parcel source) {
        start = convertZonedDateTime(source.readString());
        end = convertZonedDateTime(source.readString());
        period = convertPeriod(source.readString());
    }

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeString(convertZonedDateTime(start));
        dest.writeString(convertZonedDateTime(end));
        dest.writeString(convertPeriod(period));
    }

    public RecurrenceRule(DataInputStream in) throws IOException {
        final int version = in.readInt();
        switch (version) {
            case VERSION_INIT:
                start = convertZonedDateTime(BackupUtils.readString(in));
                end = convertZonedDateTime(BackupUtils.readString(in));
                period = convertPeriod(BackupUtils.readString(in));
            default:
                throw new ProtocolException("Unknown version " + version);
        }
    }

    public void writeToStream(DataOutputStream out) throws IOException {
        out.writeInt(VERSION_INIT);
        BackupUtils.writeString(out, convertZonedDateTime(start));
        BackupUtils.writeString(out, convertZonedDateTime(end));
        BackupUtils.writeString(out, convertPeriod(period));
    }

    @Override
    public String toString() {
        return new StringBuilder("RecurrenceRule{")
                .append("start=").append(start)
                .append(" end=").append(end)
                .append(" period=").append(period)
                .append("}").toString();
    }

    @Override
    public int hashCode() {
        return Objects.hash(start, end, period);
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof RecurrenceRule) {
            final RecurrenceRule other = (RecurrenceRule) obj;
            return Objects.equals(start, other.start)
                    && Objects.equals(end, other.end)
                    && Objects.equals(period, other.period);
        }
        return false;
    }

    public static final Parcelable.Creator<RecurrenceRule> CREATOR = new Parcelable.Creator<RecurrenceRule>() {
        @Override
        public RecurrenceRule createFromParcel(Parcel source) {
            return new RecurrenceRule(source);
        }

        @Override
        public RecurrenceRule[] newArray(int size) {
            return new RecurrenceRule[size];
        }
    };

    @Deprecated
    public boolean isMonthly() {
        return start != null
                && period != null
                && period.getYears() == 0
                && period.getMonths() == 1
                && period.getDays() == 0;
    }

    public Iterator<Pair<ZonedDateTime, ZonedDateTime>> cycleIterator() {
        if (period != null) {
            return new RecurringIterator();
        } else {
            return new NonrecurringIterator();
        }
    }

    private class NonrecurringIterator implements Iterator<Pair<ZonedDateTime, ZonedDateTime>> {
        boolean hasNext;

        public NonrecurringIterator() {
            hasNext = (start != null) && (end != null);
        }

        @Override
        public boolean hasNext() {
            return hasNext;
        }

        @Override
        public Pair<ZonedDateTime, ZonedDateTime> next() {
            hasNext = false;
            return new Pair<>(start, end);
        }
    }

    private class RecurringIterator implements Iterator<Pair<ZonedDateTime, ZonedDateTime>> {
        int i;
        ZonedDateTime cycleStart;
        ZonedDateTime cycleEnd;

        public RecurringIterator() {
            final ZonedDateTime anchor = (end != null) ? end
                    : ZonedDateTime.now(sClock).withZoneSameInstant(start.getZone());
            if (DEBUG) Log.d(TAG, "Resolving using anchor " + anchor);

            updateCycle();

            // Walk forwards until we find first cycle after now
            while (anchor.toEpochSecond() > cycleEnd.toEpochSecond()) {
                i++;
                updateCycle();
            }

            // Walk backwards until we find first cycle before now
            while (anchor.toEpochSecond() <= cycleStart.toEpochSecond()) {
                i--;
                updateCycle();
            }
        }

        private void updateCycle() {
            cycleStart = roundBoundaryTime(start.plus(period.multipliedBy(i)));
            cycleEnd = roundBoundaryTime(start.plus(period.multipliedBy(i + 1)));
        }

        private ZonedDateTime roundBoundaryTime(ZonedDateTime boundary) {
            if (isMonthly() && (boundary.getDayOfMonth() < start.getDayOfMonth())) {
                // When forced to end a monthly cycle early, we want to count
                // that entire day against the boundary.
                return ZonedDateTime.of(boundary.toLocalDate(), LocalTime.MAX, start.getZone());
            } else {
                return boundary;
            }
        }

        @Override
        public boolean hasNext() {
            return cycleStart.toEpochSecond() >= start.toEpochSecond();
        }

        @Override
        public Pair<ZonedDateTime, ZonedDateTime> next() {
            if (DEBUG) Log.d(TAG, "Cycle " + i + " from " + cycleStart + " to " + cycleEnd);
            Pair<ZonedDateTime, ZonedDateTime> p = new Pair<>(cycleStart, cycleEnd);
            i--;
            updateCycle();
            return p;
        }
    }

    public static String convertZonedDateTime(ZonedDateTime time) {
        return time != null ? time.toString() : null;
    }

    public static ZonedDateTime convertZonedDateTime(String time) {
        return time != null ? ZonedDateTime.parse(time) : null;
    }

    public static String convertPeriod(Period period) {
        return period != null ? period.toString() : null;
    }

    public static Period convertPeriod(String period) {
        return period != null ? Period.parse(period) : null;
    }
}
