package com.zhuinden.monarchy;


import android.content.Context;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;

import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import androidx.lifecycle.Observer;
import androidx.paging.DataSource;
import androidx.paging.LivePagedListBuilder;
import androidx.paging.PagedList;
import androidx.paging.PositionalDataSource;
import io.realm.OrderedCollectionChangeSet;
import io.realm.Realm;
import io.realm.RealmChangeListener;
import io.realm.RealmConfiguration;
import io.realm.RealmModel;
import io.realm.RealmQuery;
import io.realm.RealmResults;


/**
 * Monarchy is a wrapper around Realm that simplifies its lifecycle management, and leverages the power of LiveData.
 *
 * Not only does it auto-manage the Realm lifecycle, but specifically prevents the ability to use Realm in such a way that it is impossible to leave a Realm instance open by accident.
 *
 * In case of `copied`, `mapped`, or `paged` results, the queries are evaluated on a background looper thread.
 *
 * In case of `managed` results, the RealmResults is provided along with its change set.
 */
public final class Monarchy {
    private final Object LOCK = new Object();

    /**
     * A class that contains the RealmResults and the OrderedCollectionChangeSet.
     *
     * @param <T> the RealmModel type
     */
    public static class ManagedChangeSet<T extends RealmModel> {
        private final RealmResults<T> realmResults;
        private final OrderedCollectionChangeSet orderedCollectionChangeSet;

        ManagedChangeSet(RealmResults<T> realmResults, OrderedCollectionChangeSet orderedCollectionChangeSet) {
            this.realmResults = realmResults;
            this.orderedCollectionChangeSet = orderedCollectionChangeSet;
        }

        /**
         * Gets the RealmResults.
         *
         * @return the RealmResults
         */
        public RealmResults<T> getRealmResults() {
            return realmResults;
        }

        /**
         * Gets the ordered collection change set.
         *
         * @return the change set
         */
        @Nonnull
        public OrderedCollectionChangeSet getOrderedCollectionChangeSet() {
            return orderedCollectionChangeSet;
        }
    }

    private final Executor writeScheduler;

    private static volatile RealmConfiguration invalidDefaultConfig;

    /**
     * Initializes Realm as usual, except sets a default configuration to detect if a custom default is properly set.
     *
     * @param context app context
     */
    public static void init(Context context) {
        Realm.init(context);
        invalidDefaultConfig = new RealmConfiguration.Builder().build();
        Realm.setDefaultConfiguration(invalidDefaultConfig);
    }

    /**
     * Calls Realm.setDefaultConfiguration(config).
     *
     * @param realmConfiguration realm configuration
     */
    public static void setDefaultConfiguration(RealmConfiguration realmConfiguration) {
        Realm.setDefaultConfiguration(realmConfiguration);
    }

    /**
     * Returns the default configuration.
     *
     * @return the custom default configuration
     * @throws IllegalStateException if the invalid default configuration is still set.
     */
    public static RealmConfiguration getDefaultConfiguration() {
        final RealmConfiguration config = Realm.getDefaultConfiguration();
        if(config == invalidDefaultConfig) {
            throw new IllegalStateException("No default configuration is set!");
        }
        return config;
    }

    private volatile RealmConfiguration realmConfiguration = null;

    Monarchy(RealmConfiguration configuration, Executor writeScheduler) {
        this.realmConfiguration = configuration;
        this.writeScheduler = writeScheduler;
    }

    /**
     * Builder class used to build a Monarchy instance.
     *
     * You should only have a singleton instance of Monarchy.
     */
    public static class Builder {
        private RealmConfiguration realmConfiguration;
        private Executor writeScheduler = Executors.newSingleThreadExecutor();

        public Builder() {
            this.realmConfiguration = Realm.getDefaultConfiguration();
        }

        public Builder setRealmConfiguration(RealmConfiguration realmConfiguration) {
            this.realmConfiguration = realmConfiguration;
            return this;
        }

        public Builder setWriteAsyncExecutor(Executor executor) {
            this.writeScheduler = executor;
            return this;
        }

        public Monarchy build() {
            return new Monarchy(realmConfiguration, writeScheduler);
        }
    }

    public final RealmConfiguration getRealmConfiguration() {
        return this.realmConfiguration == null ? getDefaultConfiguration() : this.realmConfiguration;
    }

    public final void runTransactionSync(final Realm.Transaction transaction) {
        doWithRealm(new RealmBlock() {
            @Override
            public void doWithRealm(Realm realm) {
                realm.executeTransaction(transaction);
            }
        });
    }

    private void assertMainThread() {
        if(Looper.getMainLooper().getThread() != Thread.currentThread()) {
            throw new IllegalStateException("This method can only be called on the main thread!");
        }
    }

    private AtomicReference<HandlerThread> handlerThread = new AtomicReference<>();
    private AtomicReference<Handler> handler = new AtomicReference<>();
    private AtomicInteger refCount = new AtomicInteger(0);

    private ThreadLocal<Realm> realmThreadLocal = new ThreadLocal<>();
    private ThreadLocal<Map<LiveResults<? extends RealmModel>, RealmResults<? extends RealmModel>>> resultsRefs = new ThreadLocal<Map<LiveResults<? extends RealmModel>, RealmResults<? extends RealmModel>>>() {
        @Override
        protected Map<LiveResults<? extends RealmModel>, RealmResults<? extends RealmModel>> initialValue() {
            return new IdentityHashMap<>();
        }
    };

    // CALL THIS SYNC ON MONARCHY THREAD
    <T extends RealmModel> void createAndObserveRealmQuery(final LiveResults<T> liveResults) {
        Realm realm = realmThreadLocal.get();
        checkRealmValid(realm);
        if(liveResults == null) {
            return;
        }
        RealmResults<T> results = liveResults.createQuery(realm);
        resultsRefs.get().put(liveResults, results);
        results.addChangeListener(new RealmChangeListener<RealmResults<T>>() {
            @Override
            public void onChange(@Nonnull RealmResults<T> realmResults) {
                liveResults.updateResults(realmResults);
            }
        });
    }

    // CALL THIS SYNC ON MONARCHY THREAD
    <T extends RealmModel> void destroyRealmQuery(final LiveResults<T> liveResults) {
        Realm realm = realmThreadLocal.get();
        checkRealmValid(realm);
        if(liveResults == null) {
            return;
        }
        RealmResults<? extends RealmModel> realmResults = resultsRefs.get().remove(liveResults);
        if(realmResults != null) {
            realmResults.removeAllChangeListeners();
        }
    }

    <T extends RealmModel> void startListening(@Nullable final LiveResults<T> liveResults) {
        // build Realm instance
        if(refCount.getAndIncrement() == 0) {
            synchronized(LOCK) {
                HandlerThread handlerThread = new HandlerThread("MONARCHY_REALM-#" + hashCode());
                handlerThread.start();
                Handler handler = new Handler(handlerThread.getLooper());
                this.handlerThread.set(handlerThread);
                this.handler.set(handler);
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        Realm realm = Realm.getInstance(getRealmConfiguration());
                        if(realmThreadLocal.get() == null) {
                            realmThreadLocal.set(realm);
                        }
                    }
                });
            }
        }

        // build Realm query
        handler.get().post(new Runnable() {
            @Override
            public void run() {
                createAndObserveRealmQuery(liveResults);
            }
        });
    }

    <T extends RealmModel> void stopListening(@Nullable final LiveResults<T> liveResults) {
        Handler handler = this.handler.get();
        if(handler == null) {
            return; // edge case, hopefully doesn't happen
        }
        // destroy Realm query
        handler.post(new Runnable() {
            @Override
            public void run() {
                destroyRealmQuery(liveResults);
            }
        });
        // destroy Realm instance
        handler.post(new Runnable() {
            @Override
            public void run() {
                synchronized(LOCK) {
                    if(refCount.decrementAndGet() == 0) {
                        Realm realm = realmThreadLocal.get();
                        checkRealmValid(realm);
                        realm.close();
                        if(Realm.getLocalInstanceCount(getRealmConfiguration()) <= 0) {
                            realmThreadLocal.set(null);
                        }
                        HandlerThread handlerThread = Monarchy.this.handlerThread.getAndSet(null);
                        Monarchy.this.handler.set(null);
                        handlerThread.quit();
                    }
                }
            }
        });
    }

    private void checkRealmValid(Realm realm) {
        if(realm == null || realm.isClosed()) {
            throw new IllegalStateException("Unexpected state: Realm is not open");
        }
    }

    /**
     * An interface used to define Realm queries, therefore bypassing the thread-local aspect of RealmQuery.
     *
     * @param <T> the realm class
     */
    public interface Query<T extends RealmModel> {
        RealmQuery<T> createQuery(Realm realm);
    }

    /**
     * A mapper interface that can be used in {@link Monarchy#findAllMappedWithChanges(Query, Mapper)} to map out instances on the background looper thread.
     *
     * @param <R> the type to map to
     * @param <T> the type to map from
     */
    public interface Mapper<R, T> {
        R map(T from);
    }

    /**
     * Allows to manually use Realm instance and will automatically close it when done.
     *
     * @param realmBlock the Realm execution block in which Realm should remain open
     */
    public final void doWithRealm(final RealmBlock realmBlock) {
        RealmConfiguration configuration = getRealmConfiguration();
        Realm realm = null;
        try {
            realm = Realm.getInstance(configuration);
            realmBlock.doWithRealm(realm);
        } finally {
            if(realm != null) {
                realm.close();
            }
        }
    }

    /**
     * Writes asynchronously on a single-threaded execution pool.
     *
     * @param transaction the Realm transaction
     */
    public final void writeAsync(final Realm.Transaction transaction) {
        writeScheduler.execute(new Runnable() {
            @Override
            public void run() {
                runTransactionSync(transaction);
            }
        });
    }

    /**
     * Interface to define what to do with the Realm instance.
     */
    public interface RealmBlock {
        void doWithRealm(Realm realm);
    }

    /**
     * Provides ability to synchronously obtain a managed RealmResults as a List, in a safe way.
     *
     * What is actually returned is a snapshot collection.
     *
     * This method only makes sense either if Realm is opened manually, or inside a {@link Monarchy#doWithRealm(RealmBlock)} (or {@link Monarchy#runTransactionSync(Realm.Transaction)} method).
     *
     * @param realm Realm
     * @param query Query
     * @param <T>   RealmObject type
     * @return the snapshot collection
     */
    public <T extends RealmModel> List<T> fetchAllManagedSync(Realm realm, Query<T> query) {
        return query.createQuery(realm).findAll().createSnapshot();
    }

    /**
     * Provides ability to synchronously fetch a copied RealmResults.
     *
     * @param query Query
     * @param <T>   RealmObject type
     * @return the copied list
     */
    public <T extends RealmModel> List<T> fetchAllCopiedSync(final Query<T> query) {
        final AtomicReference<List<T>> ref = new AtomicReference<>();
        doWithRealm(new RealmBlock() {
            @Override
            public void doWithRealm(Realm realm) {
                ref.set(realm.copyFromRealm(query.createQuery(realm).findAll()));
            }
        });
        return Collections.unmodifiableList(ref.get());
    }

    /**
     * Provides ability to synchronously fetch a mapped RealmResults.
     *
     * @param query Query
     * @param <T>   RealmObject type
     * @param <U>   the mapped type
     * @return the copied list
     */
    public <T extends RealmModel, U> List<U> fetchAllMappedSync(final Query<T> query, final Mapper<U, T> mapper) {
        final AtomicReference<List<U>> ref = new AtomicReference<>();
        doWithRealm(new RealmBlock() {
            @Override
            public void doWithRealm(Realm realm) {
                RealmResults<T> results = query.createQuery(realm).findAll();
                List<U> list = new ArrayList<>(results.size());
                for(T t : results) {
                    list.add(mapper.map(t));
                }
                ref.set(list);
            }
        });
        return Collections.unmodifiableList(ref.get());
    }

    /**
     * Returns a LiveData that evaluates the new results on a background looper thread. The observer receives new data when the database changes.
     *
     * The items are copied out with `realm.copyFromRealm(results)`.
     *
     * @param query the query
     * @param <T>   the RealmModel type
     * @return the LiveData
     */
    public <T extends RealmModel> LiveData<List<T>> findAllCopiedWithChanges(Query<T> query) {
        assertMainThread();
        return new CopiedLiveResults<>(this, query);
    }

    /**
     * Returns a LiveData that evaluates the new results on a background looper thread. The observer receives new data when the database changes.
     *
     * The items are frozen with `realmResults.freeze()`.
     *
     * @param query the query
     * @param <T>   the RealmModel type
     * @return the LiveData
     */
    public <T extends RealmModel> LiveData<List<T>> findAllFrozenWithChanges(Query<T> query) {
        assertMainThread();
        return new FrozenLiveResults<>(this, query);
    }

    /**
     * Returns a LiveData that evaluates the new results on a background looper thread. The observer receives new data when the database changes.
     *
     * The items are mapped out with the provided {@link Mapper}.
     *
     * @param query the query
     * @param <T>   the RealmModel type
     * @param <U>   the mapped type
     * @return the LiveData
     */
    public <T extends RealmModel, U> LiveData<List<U>> findAllMappedWithChanges(Query<T> query, Mapper<U, T> mapper) {
        assertMainThread();
        return new MappedLiveResults<>(this, query, mapper);
    }

    private <T extends RealmModel> LiveData<ManagedChangeSet<T>> findAllManagedWithChanges(Query<T> query, boolean asAsync) {
        assertMainThread();
        return new ManagedLiveResults<>(this, query, asAsync);
    }

    /**
     * Returns a LiveData that evaluates the new results on the UI thread, using Realm's Async Query API. The observer receives new data when the database changes.
     *
     * The managed change set contains the OrderedCollectionChangeSet evaluated by Realm.
     *
     * @param query the query
     * @param <T>   the RealmModel type
     * @return the LiveData
     */
    public <T extends RealmModel> LiveData<ManagedChangeSet<T>> findAllManagedWithChanges(Query<T> query) {
        return findAllManagedWithChanges(query, true);
    }

    /**
     * Returns a LiveData that evaluates the new results on the UI thread, using Realm's Sync Query API. The observer receives new data when the database changes.
     *
     * The managed change set contains the OrderedCollectionChangeSet evaluated by Realm.
     *
     * @param query the query
     * @param <T>   the RealmModel type
     * @return the LiveData
     */
    public <T extends RealmModel> LiveData<ManagedChangeSet<T>> findAllManagedWithChangesSync(Query<T> query) {
        return findAllManagedWithChanges(query, false);
    }

    private final AtomicBoolean isForcedOpen = new AtomicBoolean(false);

    /**
     * Forcefully opens the Monarchy thread, keeping it alive until {@link Monarchy#closeManually()} is called.
     */
    public void openManually() {
        if(isForcedOpen.compareAndSet(false, true)) {
            startListening(null);
        } else {
            throw new IllegalStateException("The Monarchy thread is already forced open.");
        }
    }

    /**
     * If the Monarchy thread was opened manually, then this method can be used to decrement the forced reference count increment.
     *
     * This means that the Monarchy thread does not stop unless all observed LiveData are also inactive.
     */
    public void closeManually() {
        if(isForcedOpen.compareAndSet(true, false)) {
            stopListening(null);
        } else {
            throw new IllegalStateException("Cannot close Monarchy thread manually if it was not opened manually.");
        }
    }

    /**
     * Returns if the Monarchy thread is open.
     *
     * @return if the monarchy thread is open
     */
    public boolean isMonarchyThreadOpen() {
        synchronized(LOCK) {
            return handler.get() != null;
        }
    }

    /**
     * Posts the RealmBlock to the Monarchy thread, and executes it there.
     *
     * @param realmBlock the Realm block
     * @throws IllegalStateException if the Monarchy thread is not open
     */
    public void postToMonarchyThread(final RealmBlock realmBlock) {
        final Handler _handler = handler.get();
        if(_handler == null) {
            throw new IllegalStateException("Cannot post to Monarchy thread when the Monarchy thread is not open.");
        } else {
            _handler.post(new Runnable() {
                @Override
                public void run() {
                    Realm realm = realmThreadLocal.get();
                    checkRealmValid(realm);
                    realmBlock.doWithRealm(realm);
                }
            });
        }
    }

    ////////////////////////////////////////////////////////////////////////////////
    // PAGING
    ////////////////////////////////////////////////////////////////////////////////

    /**
     * Creates a DataSource.Factory of (Integer, T) that can be used for creating a paged result set.
     *
     * By default behavior, the created query is synchronous.
     *
     * @param query the query
     */
    public <T extends RealmModel> RealmDataSourceFactory<T> createDataSourceFactory(Query<T> query) {
        assertMainThread();
        PagedLiveResults<T> liveResults = new PagedLiveResults<T>(this, query, false);
        return new RealmDataSourceFactory<>(this, liveResults);
    }

    /**
     * Returns a LiveData that evaluates the new results on a background looper thread.
     *
     * The resulting list is driven by a PositionalDataSource from the Paging Library.
     *
     * The fetch executor of the provided LivePagedListBuilder will be overridden with Monarchy's own FetchExecutor that Monarchy runs its queries on.
     */
    public <R, T extends RealmModel> LiveData<PagedList<R>> findAllPagedWithChanges(DataSource.Factory<Integer, T> dataSourceFactory, LivePagedListBuilder<Integer, R> livePagedListBuilder) {
        assertMainThread();
        final MediatorLiveData<PagedList<R>> mediator = new MediatorLiveData<>();
        if(!(dataSourceFactory instanceof RealmDataSourceFactory)) {
            throw new IllegalArgumentException(
                    "The DataSource.Factory provided to this method as the first argument must be the one created by Monarchy.");
        }
        RealmDataSourceFactory<T> realmDataSourceFactory = (RealmDataSourceFactory<T>) dataSourceFactory;
        PagedLiveResults<T> liveResults = realmDataSourceFactory.pagedLiveResults;
        mediator.addSource(liveResults, new Observer<PagedList<T>>() {
            @Override
            public void onChanged(@Nullable PagedList<T> ts) {
                // do nothing, this is to intercept `onActive()` calls to ComputableLiveData
            }
        });
        LiveData<PagedList<R>> computableLiveData = livePagedListBuilder
                .setFetchExecutor(new RealmQueryExecutor(this))
                .build();
        mediator.addSource(computableLiveData, new Observer<PagedList<R>>() {
            @Override
            public void onChanged(@Nullable PagedList<R> data) {
                mediator.postValue(data);
            }
        });
        return mediator;
    }

    private static class RealmQueryExecutor
            implements Executor {
        final Monarchy monarchy;

        public RealmQueryExecutor(Monarchy monarchy) {
            this.monarchy = monarchy;
        }

        @Override
        public void execute(@Nonnull Runnable command) {
            Handler handler = monarchy.handler.get();
            if(handler == null) {
                return; // this happens if the gap worker tries to fetch a new page,
                // but the results is no longer observed, and the handler thread is dead.
            }
            if(Looper.myLooper() == handler.getLooper()) {
                command.run();
            } else {
                handler.post(command);
            }
        }
    }

    /**
     * From Paging Library
     */
    static abstract class TiledDataSource<T>
            extends PositionalDataSource<T> {
        @WorkerThread
        public abstract int countItems();

        @WorkerThread
        public abstract List<T> loadRange(int startPosition, int count);

        @Override
        public final void loadInitial(@Nonnull LoadInitialParams params,
                                      @Nonnull LoadInitialCallback<T> callback) {
            int totalCount = countItems();
            if(totalCount == 0) {
                callback.onResult(Collections.<T>emptyList(), 0, 0);
                return;
            }

            // bound the size requested, based on known count
            final int firstLoadPosition = computeInitialLoadPosition(params, totalCount);
            final int firstLoadSize = computeInitialLoadSize(params, firstLoadPosition, totalCount);

            // convert from legacy behavior
            List<T> list = loadRange(firstLoadPosition, firstLoadSize);
            if(list != null && list.size() == firstLoadSize) {
                callback.onResult(list, firstLoadPosition, totalCount);
            } else {
                // null list, or size doesn't match request
                // The size check is a WAR for Room 1.0, subsequent versions do the check in Room
                invalidate();
            }
        }

        @Override
        public final void loadRange(@Nonnull LoadRangeParams params,
                                    @Nonnull LoadRangeCallback<T> callback) {
            List<T> list = loadRange(params.startPosition, params.loadSize);
            if(list != null) {
                callback.onResult(list);
            } else {
                invalidate();
            }
        }
    }

    static class RealmTiledDataSource<T extends RealmModel>
            extends TiledDataSource<T> {
        final Monarchy monarchy;
        final LiveResults<T> liveResults;

        // WORKER THREAD
        public RealmTiledDataSource(Monarchy monarchy, LiveResults<T> liveResults) {
            this.monarchy = monarchy;
            this.liveResults = liveResults;
        }

        @SuppressWarnings("unchecked")
        @WorkerThread
        public int countItems() {
            Realm realm = monarchy.realmThreadLocal.get();
            RealmResults<T> results = (RealmResults<T>) monarchy.resultsRefs.get().get(liveResults);
            if(realm.isClosed() || results == null || !results.isValid() || !results.isLoaded()) {
                return 0;
            }
            return results.size();
        }

        @Override
        public boolean isInvalid() {
            Realm realm = monarchy.realmThreadLocal.get();
            realm.refresh();
            return super.isInvalid();
        }

        @SuppressWarnings("unchecked")
        @WorkerThread
        @Override
        public List<T> loadRange(final int startPosition, final int count) {
            final int countItems = countItems();
            if(countItems == 0) {
                return Collections.emptyList();
            }
            final List<T> list = new ArrayList<>(count);
            monarchy.doWithRealm(new Monarchy.RealmBlock() {
                @Override
                public void doWithRealm(Realm realm) {
                    RealmResults<T> results = (RealmResults<T>) monarchy.resultsRefs.get().get(liveResults);
                    for(int i = startPosition; i < startPosition + count && i < countItems; i++) {
                        // noinspection ConstantConditions
                        list.add(realm.copyFromRealm(results.get(i)));
                    }
                }
            });

            return Collections.unmodifiableList(list);
        }
    }

    /**
     * A DataSource.Factory that handles integration of RealmResults through Paging.
     *
     * @param <T> the type of the RealmModel
     */
    public static final class RealmDataSourceFactory<T extends RealmModel>
            extends DataSource.Factory<Integer, T> {
        final Monarchy monarchy;
        final PagedLiveResults<T> pagedLiveResults;

        RealmDataSourceFactory(Monarchy monarchy, PagedLiveResults<T> pagedLiveResults) {
            this.monarchy = monarchy;
            this.pagedLiveResults = pagedLiveResults;
        }

        @Override
        public final DataSource<Integer, T> create() {
            RealmTiledDataSource<T> dataSource = new RealmTiledDataSource<>(monarchy, pagedLiveResults);
            pagedLiveResults.setDataSource(dataSource);
            return dataSource;
        }

        /**
         * Updates the query that the datasource is evaluated by.
         *
         * Please note that this method runs asynchronously.
         *
         * @param query the query
         */
        public final void updateQuery(final Query<T> query) {
            Handler handler = monarchy.handler.get();
            if(handler == null) {
                return;
            }
            handler.post(new Runnable() {
                @Override
                public void run() {
                    monarchy.destroyRealmQuery(pagedLiveResults);
                    pagedLiveResults.updateQuery(query);
                    monarchy.createAndObserveRealmQuery(pagedLiveResults);
                    pagedLiveResults.invalidateDatasource();
                }
            });

        }
    }

    ////////////////////////////////////////////////////////////////////////////////
    // PAGING END
    ////////////////////////////////////////////////////////////////////////////////
}