/*
 * Copyright 2020 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 com.android.car.calendar.common;

import static com.google.common.base.Preconditions.checkState;

import static java.time.temporal.ChronoUnit.DAYS;
import static java.time.temporal.ChronoUnit.MINUTES;

import android.content.ContentResolver;
import android.content.ContentUris;
import android.database.ContentObserver;
import android.database.Cursor;
import android.net.Uri;
import android.os.Handler;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Instances;
import android.util.Log;

import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;

import javax.annotation.Nullable;

/**
 * An observable source of calendar events coming from the <a
 * href="https://developer.android.com/guide/topics/providers/calendar-provider">Calendar
 * Provider</a>.
 *
 * <p>While in the active state the content provider is observed for changes.
 *
 * <p>When the value given to the observer is null it signals that there are no calendars.
 */
public class EventsLiveData extends LiveData<ImmutableList<Event>> {

    private static final String TAG = "CarCalendarEventsLiveData";
    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);

    // The duration to delay before updating the value to reduce the frequency of changes.
    private static final int UPDATE_DELAY_MILLIS = 1000;

    // Sort events by start date and title.
    private static final Comparator<Event> EVENT_COMPARATOR =
            Comparator.comparing(Event::getDayStartInstant).thenComparing(Event::getTitle);

    private final Clock mClock;
    private final Handler mBackgroundHandler;
    private final ContentResolver mContentResolver;
    private final EventDescriptions mEventDescriptions;
    private final EventLocations mLocations;
    private final Runnable mUpdateIfChangedRunnable = this::updateIfChanged;

    /** The event instances cursor is a field to allow observers to be managed. */
    @Nullable private Cursor mEventsCursor;

    @Nullable private ContentObserver mEventInstancesObserver;

    // This can be updated on the background thread but read from any thread.
    private volatile boolean mValueUpdated;

    public EventsLiveData(
            Clock clock,
            Handler backgroundHandler,
            ContentResolver contentResolver,
            EventDescriptions eventDescriptions,
            EventLocations locations) {
        mClock = clock;
        mBackgroundHandler = backgroundHandler;
        mContentResolver = contentResolver;
        mEventDescriptions = eventDescriptions;
        mLocations = locations;
    }

    /** Refreshes the event instances and sets the new value which notifies observers. */
    private void updateIfChanged() {
        Log.d(TAG, "Update if changed");
        ImmutableList<Event> latest = getEventsUntilTomorrow();
        ImmutableList<Event> current = getValue();

        // Always post the first value even if it is null.
        if (!mValueUpdated || !Objects.equals(latest, current)) {
            postValue(latest);
            mValueUpdated = true;
        }
    }

    /** Queries the content provider for event instances. */
    @Nullable
    private ImmutableList<Event> getEventsUntilTomorrow() {
        // Check we are running on our background thread.
        checkState(mBackgroundHandler.getLooper().isCurrentThread());

        if (mEventsCursor != null) {
            tearDownCursor();
        }

        ZonedDateTime now = ZonedDateTime.now(mClock);

        // Find all events in the current day to include any all-day events.
        ZonedDateTime startDateTime = now.truncatedTo(DAYS);
        ZonedDateTime endDateTime = startDateTime.plusDays(2).truncatedTo(ChronoUnit.DAYS);

        // Always create the cursor so we can observe it for changes to events.
        mEventsCursor = createEventsCursor(startDateTime, endDateTime);

        // If there are no calendars we return null
        if (!hasCalendars()) {
            return null;
        }

        List<Event> events = new ArrayList<>();
        while (mEventsCursor.moveToNext()) {
            List<Event> eventsForRow = createEventsForRow(mEventsCursor, mEventDescriptions);
            for (Event event : eventsForRow) {
                // Filter out any events that do not overlap the time window.
                if (event.getDayEndInstant().isBefore(now.toInstant())
                        || !event.getDayStartInstant().isBefore(endDateTime.toInstant())) {
                    continue;
                }
                events.add(event);
            }
        }
        events.sort(EVENT_COMPARATOR);
        return ImmutableList.copyOf(events);
    }

    private boolean hasCalendars() {
        try (Cursor cursor =
                mContentResolver.query(CalendarContract.Calendars.CONTENT_URI, null, null, null)) {
            return cursor == null || cursor.getCount() > 0;
        }
    }

    /** Creates a new {@link Cursor} over event instances with an updated time range. */
    private Cursor createEventsCursor(ZonedDateTime startDateTime, ZonedDateTime endDateTime) {
        Uri.Builder eventInstanceUriBuilder = CalendarContract.Instances.CONTENT_URI.buildUpon();
        if (DEBUG) Log.d(TAG, "Reading from " + startDateTime + " to " + endDateTime);

        ContentUris.appendId(eventInstanceUriBuilder, startDateTime.toInstant().toEpochMilli());
        ContentUris.appendId(eventInstanceUriBuilder, endDateTime.toInstant().toEpochMilli());
        Uri eventInstanceUri = eventInstanceUriBuilder.build();
        Cursor cursor =
                mContentResolver.query(
                        eventInstanceUri,
                        /* projection= */ null,
                        /* selection= */ null,
                        /* selectionArgs= */ null,
                        Instances.BEGIN);

        // Set an observer on the Cursor, not the ContentResolver so it can be mocked for tests.
        mEventInstancesObserver =
                new ContentObserver(mBackgroundHandler) {
                    @Override
                    public boolean deliverSelfNotifications() {
                        return true;
                    }

                    @Override
                    public void onChange(boolean selfChange) {
                        if (DEBUG) Log.d(TAG, "Events changed");
                        updateWithDelay();
                    }
                };
        cursor.setNotificationUri(mContentResolver, eventInstanceUri);
        cursor.registerContentObserver(mEventInstancesObserver);

        return cursor;
    }

    private void updateWithDelay() {
        // Do not update the events until there have been no changes for a given duration.
        Log.d(TAG, "Events changed");
        mBackgroundHandler.removeCallbacks(mUpdateIfChangedRunnable);
        mBackgroundHandler.postDelayed(mUpdateIfChangedRunnable, UPDATE_DELAY_MILLIS);
    }

    /** Can return multiple events for a single cursor row when an event spans multiple days. */
    private List<Event> createEventsForRow(
            Cursor eventInstancesCursor, EventDescriptions eventDescriptions) {
        String titleText = text(eventInstancesCursor, Instances.TITLE);

        boolean allDay = integer(eventInstancesCursor, CalendarContract.Events.ALL_DAY) == 1;
        String descriptionText = text(eventInstancesCursor, Instances.DESCRIPTION);

        long startTimeMs = integer(eventInstancesCursor, Instances.BEGIN);
        long endTimeMs = integer(eventInstancesCursor, Instances.END);

        Instant startInstant = Instant.ofEpochMilli(startTimeMs);
        Instant endInstant = Instant.ofEpochMilli(endTimeMs);

        // If an event is all-day then the times are stored in UTC and must be adjusted.
        if (allDay) {
            startInstant = utcToDefaultTimeZone(startInstant);
            endInstant = utcToDefaultTimeZone(endInstant);
        }

        String locationText = text(eventInstancesCursor, Instances.EVENT_LOCATION);
        if (!mLocations.isValidLocation(locationText)) {
            locationText = null;
        }

        List<Dialer.NumberAndAccess> numberAndAccesses =
                eventDescriptions.extractNumberAndPins(descriptionText);
        Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null);
        long calendarColor = integer(eventInstancesCursor, Instances.CALENDAR_COLOR);
        String calendarName = text(eventInstancesCursor, Instances.CALENDAR_DISPLAY_NAME);
        int selfAttendeeStatus =
                (int) integer(eventInstancesCursor, Instances.SELF_ATTENDEE_STATUS);

        Event.Status status;
        switch (selfAttendeeStatus) {
            case CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED:
                status = Event.Status.ACCEPTED;
                break;
            case CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED:
                status = Event.Status.DECLINED;
                break;
            default:
                status = Event.Status.NONE;
        }

        // Add an Event for each day of events that span multiple days.
        List<Event> events = new ArrayList<>();
        Instant dayStartInstant =
                startInstant.atZone(mClock.getZone()).truncatedTo(DAYS).toInstant();
        Instant dayEndInstant;
        do {
            dayEndInstant = dayStartInstant.plus(1, DAYS);
            events.add(
                    new Event(
                            allDay,
                            startInstant,
                            dayStartInstant.isAfter(startInstant) ? dayStartInstant : startInstant,
                            endInstant,
                            dayEndInstant.isBefore(endInstant) ? dayEndInstant : endInstant,
                            titleText,
                            status,
                            locationText,
                            numberAndAccess,
                            new Event.CalendarDetails(calendarName, (int) calendarColor)));
            dayStartInstant = dayEndInstant;
        } while (dayStartInstant.isBefore(endInstant));
        return events;
    }

    private Instant utcToDefaultTimeZone(Instant instant) {
        return instant.atZone(ZoneId.of("UTC")).withZoneSameLocal(mClock.getZone()).toInstant();
    }

    @Override
    protected void onActive() {
        super.onActive();
        if (DEBUG) Log.d(TAG, "Live data active");
        mBackgroundHandler.post(this::updateAndScheduleNext);
    }

    @Override
    protected void onInactive() {
        super.onInactive();
        if (DEBUG) Log.d(TAG, "Live data inactive");
        mBackgroundHandler.post(this::cancelScheduledUpdate);
        mBackgroundHandler.post(this::tearDownCursor);
        mValueUpdated = false;
    }

    /** Calls {@link #updateIfChanged()} every minute to keep the displayed time range correct. */
    private void updateAndScheduleNext() {
        if (DEBUG) Log.d(TAG, "Update and schedule");
        if (hasActiveObservers()) {
            updateIfChanged();
            ZonedDateTime now = ZonedDateTime.now(mClock);
            ZonedDateTime truncatedNowTime = now.truncatedTo(MINUTES);
            ZonedDateTime updateTime = truncatedNowTime.plus(1, MINUTES);
            long delayMs = updateTime.toInstant().toEpochMilli() - now.toInstant().toEpochMilli();
            if (DEBUG) Log.d(TAG, "Scheduling in " + delayMs);
            mBackgroundHandler.postDelayed(this::updateAndScheduleNext, this, delayMs);
        }
    }

    private void cancelScheduledUpdate() {
        mBackgroundHandler.removeCallbacksAndMessages(this);
    }

    private void tearDownCursor() {
        if (mEventsCursor != null) {
            if (DEBUG) Log.d(TAG, "Closing cursor and unregistering observer");
            mEventsCursor.unregisterContentObserver(mEventInstancesObserver);
            mEventsCursor.close();
            mEventsCursor = null;
        } else {
            // Should not happen as the cursor should have been created first on the same handler.
            Log.w(TAG, "Expected cursor");
        }
    }

    private static String text(Cursor cursor, String columnName) {
        return cursor.getString(cursor.getColumnIndex(columnName));
    }

    /** An integer for the content provider is actually a Java long. */
    private static long integer(Cursor cursor, String columnName) {
        return cursor.getLong(cursor.getColumnIndex(columnName));
    }
}
