/*
 * Copyright (C) 2022 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.systemui.shared.condition;

import android.util.ArraySet;
import android.util.Log;

import androidx.annotation.NonNull;

import com.android.systemui.dagger.qualifiers.Main;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Set;
import java.util.concurrent.Executor;

import javax.inject.Inject;

/**
 * {@link Monitor} takes in a set of conditions, monitors whether all of them have
 * been fulfilled, and informs any registered listeners.
 */
public class Monitor {
    private final String mTag = getClass().getSimpleName();
    private final Executor mExecutor;
    private final Set<Condition> mPreconditions;

    private final HashMap<Condition, ArraySet<Subscription.Token>> mConditions = new HashMap<>();
    private final HashMap<Subscription.Token, SubscriptionState> mSubscriptions = new HashMap<>();

    private static class SubscriptionState {
        private final Subscription mSubscription;

        // A subscription must maintain a reference to any active nested subscription so that it may
        // be later removed when the current subscription becomes invalid.
        private Subscription.Token mNestedSubscriptionToken;
        private Boolean mAllConditionsMet;
        private boolean mActive;

        SubscriptionState(Subscription subscription) {
            mSubscription = subscription;
        }

        public Set<Condition> getConditions() {
            return mSubscription.mConditions;
        }

        /**
         * Signals that the {@link Subscription} is now being monitored and will receive updates
         * based on its conditions.
         */
        private void setActive(boolean active) {
            if (mActive == active) {
                return;
            }

            mActive = active;

            final Callback callback = mSubscription.getCallback();

            if (callback == null) {
                return;
            }

            callback.onActiveChanged(active);
        }

        public void update(Monitor monitor) {
            final Boolean result = Evaluator.INSTANCE.evaluate(mSubscription.mConditions,
                    Evaluator.OP_AND);
            // Consider unknown (null) as true
            final boolean newAllConditionsMet = result == null || result;

            if (mAllConditionsMet != null && newAllConditionsMet == mAllConditionsMet) {
                return;
            }

            mAllConditionsMet = newAllConditionsMet;

            final Subscription nestedSubscription = mSubscription.getNestedSubscription();

            if (nestedSubscription != null) {
                if (mAllConditionsMet && mNestedSubscriptionToken == null) {
                    // When all conditions are met for a subscription with a nested subscription
                    // that is not currently being monitored, add the nested subscription for
                    // monitor.
                    mNestedSubscriptionToken =
                            monitor.addSubscription(nestedSubscription, null);
                } else if (!mAllConditionsMet && mNestedSubscriptionToken != null) {
                    // When conditions are not met and there is an active nested condition, remove
                    // the nested condition from monitoring.
                    removeNestedSubscription(monitor);
                }
                return;
            }

            mSubscription.getCallback().onConditionsChanged(mAllConditionsMet);
        }

        /**
         * Invoked when the {@link Subscription} has been added to the {@link Monitor}.
         */
        public void onAdded() {
            setActive(true);
        }

        /**
         * Invoked when the {@link Subscription} has been removed from the {@link Monitor},
         * allowing cleanup code to run.
         */
        public void onRemoved(Monitor monitor) {
            setActive(false);
            removeNestedSubscription(monitor);
        }

        private void removeNestedSubscription(Monitor monitor) {
            if (mNestedSubscriptionToken == null) {
                return;
            }

            monitor.removeSubscription(mNestedSubscriptionToken);
            mNestedSubscriptionToken = null;
        }
    }

    // Callback for when each condition has been updated.
    private final Condition.Callback mConditionCallback = new Condition.Callback() {
        @Override
        public void onConditionChanged(Condition condition) {
            mExecutor.execute(() -> updateConditionMetState(condition));
        }
    };

    /**
     * Constructor for injected use-cases. By default, no preconditions are present.
     */
    @Inject
    public Monitor(@Main Executor executor) {
        this(executor, Collections.emptySet());
    }

    /**
     * Main constructor, allowing specifying preconditions.
     */
    public Monitor(Executor executor, Set<Condition> preconditions) {
        mExecutor = executor;
        mPreconditions = preconditions;
    }

    private void updateConditionMetState(Condition condition) {
        final ArraySet<Subscription.Token> subscriptions = mConditions.get(condition);

        // It's possible the condition was removed between the time the callback occurred and
        // update was executed on the main thread.
        if (subscriptions == null) {
            return;
        }

        subscriptions.stream().forEach(token -> mSubscriptions.get(token).update(this));
    }

    /**
     * Registers a callback and the set of conditions to trigger it.
     *
     * @param subscription A {@link Subscription} detailing the desired conditions and callback.
     * @return A {@link Subscription.Token} that can be used to remove the subscription.
     */
    public Subscription.Token addSubscription(@NonNull Subscription subscription) {
        return addSubscription(subscription, mPreconditions);
    }

    private Subscription.Token addSubscription(@NonNull Subscription subscription,
            Set<Condition> preconditions) {
        // If preconditions are set on the monitor, set up as a nested condition.
        final Subscription normalizedCondition = preconditions != null
                ? new Subscription.Builder(subscription).addConditions(preconditions).build()
                : subscription;

        final Subscription.Token token = new Subscription.Token();
        final SubscriptionState state = new SubscriptionState(normalizedCondition);

        mExecutor.execute(() -> {
            if (shouldLog()) Log.d(mTag, "adding subscription");
            mSubscriptions.put(token, state);

            // Add and associate conditions.
            normalizedCondition.getConditions().stream().forEach(condition -> {
                if (!mConditions.containsKey(condition)) {
                    mConditions.put(condition, new ArraySet<>());
                    condition.addCallback(mConditionCallback);
                }

                mConditions.get(condition).add(token);
            });

            state.onAdded();

            // Update subscription state.
            state.update(this);

        });
        return token;
    }

    /**
     * Removes a subscription from participating in future callbacks.
     *
     * @param token The {@link Subscription.Token} returned when the {@link Subscription} was
     *              originally added.
     */
    public void removeSubscription(@NonNull Subscription.Token token) {
        mExecutor.execute(() -> {
            if (shouldLog()) Log.d(mTag, "removing subscription");
            if (!mSubscriptions.containsKey(token)) {
                Log.e(mTag, "subscription not present:" + token);
                return;
            }

            final SubscriptionState removedSubscription = mSubscriptions.remove(token);

            removedSubscription.getConditions().forEach(condition -> {
                if (!mConditions.containsKey(condition)) {
                    Log.e(mTag, "condition not present:" + condition);
                    return;

                }
                final Set<Subscription.Token> conditionSubscriptions = mConditions.get(condition);

                conditionSubscriptions.remove(token);
                if (conditionSubscriptions.isEmpty()) {
                    condition.removeCallback(mConditionCallback);
                    mConditions.remove(condition);
                }
            });

            removedSubscription.onRemoved(this);
        });
    }

    private boolean shouldLog() {
        return Log.isLoggable(mTag, Log.DEBUG);
    }

    /**
     * A {@link Subscription} represents a set of conditions and a callback that is informed when
     * these conditions change.
     */
    public static class Subscription {
        private final Set<Condition> mConditions;
        private final Callback mCallback;

        // A nested {@link Subscription} is a special callback where the specified condition's
        // active state is dependent on the conditions of the parent {@link Subscription} being met.
        // Once active, the nested subscription's conditions are registered as normal with the
        // monitor and its callback (which could also be a nested condition) is triggered based on
        // those conditions. The nested condition will be removed from monitor if the outer
        // subscription's conditions ever become invalid.
        private final Subscription mNestedSubscription;

        private Subscription(Set<Condition> conditions, Callback callback,
                Subscription nestedSubscription) {
            this.mConditions = Collections.unmodifiableSet(conditions);
            this.mCallback = callback;
            this.mNestedSubscription = nestedSubscription;
        }

        public Set<Condition> getConditions() {
            return mConditions;
        }

        public Callback getCallback() {
            return mCallback;
        }

        public Subscription getNestedSubscription() {
            return mNestedSubscription;
        }

        /**
         * A {@link Token} is an identifier that is associated with a {@link Subscription} which is
         * registered with a {@link Monitor}.
         */
        public static class Token {
        }

        /**
         * {@link Builder} is a helper class for constructing a {@link Subscription}.
         */
        public static class Builder {
            private final Callback mCallback;
            private final Subscription mNestedSubscription;
            private final ArraySet<Condition> mConditions;
            private final ArraySet<Condition> mPreconditions;

            /**
             * Default constructor specifying the {@link Callback} for the {@link Subscription}.
             */
            public Builder(Callback callback) {
                this(null, callback);
            }

            public Builder(Subscription nestedSubscription) {
                this(nestedSubscription, null);
            }

            private Builder(Subscription nestedSubscription, Callback callback) {
                mNestedSubscription = nestedSubscription;
                mCallback = callback;
                mConditions = new ArraySet();
                mPreconditions = new ArraySet();
            }

            /**
             * Adds a {@link Condition} to be associated with the {@link Subscription}.
             *
             * @return The updated {@link Builder}.
             */
            public Builder addCondition(Condition condition) {
                mConditions.add(condition);
                return this;
            }

            /**
             * Adds a set of {@link Condition} to be a precondition for {@link Subscription}.
             *
             * @return The updated {@link Builder}.
             */
            public Builder addPreconditions(Set<Condition> condition) {
                if (condition == null) {
                    return this;
                }
                mPreconditions.addAll(condition);
                return this;
            }

            /**
             * Adds a {@link Condition} to be a precondition for {@link Subscription}.
             *
             * @return The updated {@link Builder}.
             */
            public Builder addPrecondition(Condition condition) {
                mPreconditions.add(condition);
                return this;
            }

            /**
             * Adds a set of {@link Condition} to be associated with the {@link Subscription}.
             *
             * @return The updated {@link Builder}.
             */
            public Builder addConditions(Set<Condition> condition) {
                if (condition == null) {
                    return this;
                }

                mConditions.addAll(condition);
                return this;
            }

            /**
             * Builds the {@link Subscription}.
             *
             * @return The resulting {@link Subscription}.
             */
            public Subscription build() {
                final Subscription subscription =
                        new Subscription(mConditions, mCallback, mNestedSubscription);
                return !mPreconditions.isEmpty()
                        ? new Subscription(mPreconditions, null, subscription)
                        : subscription;
            }
        }
    }

    /**
     * Callback that receives updates of whether all conditions have been fulfilled.
     */
    public interface Callback {
        /**
         * Returns the conditions associated with this callback.
         */
        default ArrayList<Condition> getConditions() {
            return new ArrayList<>();
        }

        /**
         * Triggered when the fulfillment of all conditions have been met.
         *
         * @param allConditionsMet True if all conditions have been fulfilled. False if none or
         *                         only partial conditions have been fulfilled.
         */
        void onConditionsChanged(boolean allConditionsMet);

        /**
         * Called when the active state of the {@link Subscription} changes.
         * @param active {@code true} when changes to the conditions will affect the
         *               {@link Subscription}, {@code false} otherwise.
         */
        default void onActiveChanged(boolean active) {
        }
    }
}
