/*
 * Copyright 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 com.android.server.wifi;

import android.text.format.DateUtils;
import android.util.ArrayMap;
import android.util.Log;

import com.android.internal.annotations.VisibleForTesting;

import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.util.Collection;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;

/**
 * A lock to determine whether Wifi Wake can re-enable Wifi.
 *
 * <p>Wakeuplock manages a list of networks to determine whether the device's location has changed.
 */
public class WakeupLock {

    private static final String TAG = WakeupLock.class.getSimpleName();

    @VisibleForTesting
    static final int CONSECUTIVE_MISSED_SCANS_REQUIRED_TO_EVICT = 5;
    @VisibleForTesting
    static final long MAX_LOCK_TIME_MILLIS = 10 * DateUtils.MINUTE_IN_MILLIS;

    private final WifiConfigManager mWifiConfigManager;
    private final Map<ScanResultMatchInfo, Integer> mLockedNetworks = new ArrayMap<>();
    private final WifiWakeMetrics mWifiWakeMetrics;
    private final Clock mClock;

    private boolean mVerboseLoggingEnabled;
    private long mLockTimestamp;
    private boolean mIsInitialized;
    private int mNumScans;

    public WakeupLock(WifiConfigManager wifiConfigManager, WifiWakeMetrics wifiWakeMetrics,
                      Clock clock) {
        mWifiConfigManager = wifiConfigManager;
        mWifiWakeMetrics = wifiWakeMetrics;
        mClock = clock;
    }

    /**
     * Sets the WakeupLock with the given {@link ScanResultMatchInfo} list.
     *
     * <p>This saves the wakeup lock to the store and begins the initialization process.
     *
     * @param scanResultList list of ScanResultMatchInfos to start the lock with
     */
    public void setLock(Collection<ScanResultMatchInfo> scanResultList) {
        mLockTimestamp = mClock.getElapsedSinceBootMillis();
        mIsInitialized = false;
        mNumScans = 0;

        mLockedNetworks.clear();
        for (ScanResultMatchInfo scanResultMatchInfo : scanResultList) {
            mLockedNetworks.put(scanResultMatchInfo, CONSECUTIVE_MISSED_SCANS_REQUIRED_TO_EVICT);
        }

        Log.d(TAG, "Lock set. Number of networks: " + mLockedNetworks.size());

        mWifiConfigManager.saveToStore();
    }

    /**
     * Maybe sets the WakeupLock as initialized based on total scans handled.
     *
     * @param numScans total number of elapsed scans in the current WifiWake session
     */
    private void maybeSetInitializedByScans(int numScans) {
        if (mIsInitialized) {
            return;
        }
        boolean shouldBeInitialized = numScans >= CONSECUTIVE_MISSED_SCANS_REQUIRED_TO_EVICT;
        if (shouldBeInitialized) {
            mIsInitialized = true;

            Log.d(TAG, "Lock initialized by handled scans. Scans: " + numScans);
            if (mVerboseLoggingEnabled) {
                Log.d(TAG, "State of lock: " + mLockedNetworks);
            }

            // log initialize event
            mWifiWakeMetrics.recordInitializeEvent(mNumScans, mLockedNetworks.size());
        }
    }

    /**
     * Maybe sets the WakeupLock as initialized based on elapsed time.
     *
     * @param timestampMillis current timestamp
     */
    private void maybeSetInitializedByTimeout(long timestampMillis) {
        if (mIsInitialized) {
            return;
        }
        long elapsedTime = timestampMillis - mLockTimestamp;
        boolean shouldBeInitialized = elapsedTime > MAX_LOCK_TIME_MILLIS;

        if (shouldBeInitialized) {
            mIsInitialized = true;

            Log.d(TAG, "Lock initialized by timeout. Elapsed time: " + elapsedTime);
            if (mNumScans == 0) {
                Log.w(TAG, "Lock initialized with 0 handled scans!");
            }
            if (mVerboseLoggingEnabled) {
                Log.d(TAG, "State of lock: " + mLockedNetworks);
            }

            // log initialize event
            mWifiWakeMetrics.recordInitializeEvent(mNumScans, mLockedNetworks.size());
        }
    }

    /** Returns whether the lock has been fully initialized. */
    public boolean isInitialized() {
        return mIsInitialized;
    }

    /**
     * Adds the given networks to the lock.
     *
     * <p>This is called during the initialization step.
     *
     * @param networkList The list of networks to be added
     */
    private void addToLock(Collection<ScanResultMatchInfo> networkList) {
        if (mVerboseLoggingEnabled) {
            Log.d(TAG, "Initializing lock with networks: " + networkList);
        }

        boolean hasChanged = false;

        for (ScanResultMatchInfo network : networkList) {
            if (!mLockedNetworks.containsKey(network)) {
                mLockedNetworks.put(network, CONSECUTIVE_MISSED_SCANS_REQUIRED_TO_EVICT);
                hasChanged = true;
            }
        }

        if (hasChanged) {
            mWifiConfigManager.saveToStore();
        }

        // Set initialized if the lock has handled enough scans, and log the event
        maybeSetInitializedByScans(mNumScans);
    }

    /**
     * Removes networks from the lock if not present in the given {@link ScanResultMatchInfo} list.
     *
     * <p>If a network in the lock is not present in the list, reduce the number of scans
     * required to evict by one. Remove any entries in the list with 0 scans required to evict. If
     * any entries in the lock are removed, the store is updated.
     *
     * @param networkList list of present ScanResultMatchInfos to update the lock with
     */
    private void removeFromLock(Collection<ScanResultMatchInfo> networkList) {
        if (mVerboseLoggingEnabled) {
            Log.d(TAG, "Filtering lock with networks: " + networkList);
        }

        boolean hasChanged = false;
        Iterator<Map.Entry<ScanResultMatchInfo, Integer>> it =
                mLockedNetworks.entrySet().iterator();
        while (it.hasNext()) {
            Map.Entry<ScanResultMatchInfo, Integer> entry = it.next();

            // if present in scan list, reset to max
            if (networkList.contains(entry.getKey())) {
                if (mVerboseLoggingEnabled) {
                    Log.d(TAG, "Found network in lock: " + entry.getKey().networkSsid);
                }
                entry.setValue(CONSECUTIVE_MISSED_SCANS_REQUIRED_TO_EVICT);
                continue;
            }

            // decrement and remove if necessary
            entry.setValue(entry.getValue() - 1);
            if (entry.getValue() <= 0) {
                Log.d(TAG, "Removed network from lock: " + entry.getKey().networkSsid);
                it.remove();
                hasChanged = true;
            }
        }

        if (hasChanged) {
            mWifiConfigManager.saveToStore();
        }

        if (isUnlocked()) {
            Log.d(TAG, "Lock emptied. Recording unlock event.");
            mWifiWakeMetrics.recordUnlockEvent(mNumScans);
        }
    }

    /**
     * Updates the lock with the given {@link ScanResultMatchInfo} list.
     *
     * <p>Based on the current initialization state of the lock, either adds or removes networks
     * from the lock.
     *
     * <p>The lock is initialized after {@link #CONSECUTIVE_MISSED_SCANS_REQUIRED_TO_EVICT}
     * scans have been handled, or after {@link #MAX_LOCK_TIME_MILLIS} milliseconds have elapsed
     * since {@link #setLock(Collection)}.
     *
     * @param networkList list of present ScanResultMatchInfos to update the lock with
     */
    public void update(Collection<ScanResultMatchInfo> networkList) {
        // update is no-op if already unlocked
        if (isUnlocked()) {
            return;
        }
        // Before checking handling the scan, we check to see whether we've exceeded the maximum
        // time allowed for initialization. If so, we set initialized and treat this scan as a
        // "removeFromLock()" instead of an "addToLock()".
        maybeSetInitializedByTimeout(mClock.getElapsedSinceBootMillis());

        mNumScans++;

        // add or remove networks based on initialized status
        if (mIsInitialized) {
            removeFromLock(networkList);
        } else {
            addToLock(networkList);
        }
    }

    /** Returns whether the WakeupLock is unlocked */
    public boolean isUnlocked() {
        return mIsInitialized && mLockedNetworks.isEmpty();
    }

    /** Returns the data source for the WakeupLock config store data. */
    public WakeupConfigStoreData.DataSource<Set<ScanResultMatchInfo>> getDataSource() {
        return new WakeupLockDataSource();
    }

    /** Dumps wakeup lock contents. */
    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
        pw.println("WakeupLock: ");
        pw.println("mNumScans: " + mNumScans);
        pw.println("mIsInitialized: " + mIsInitialized);
        pw.println("Locked networks: " + mLockedNetworks.size());
        for (Map.Entry<ScanResultMatchInfo, Integer> entry : mLockedNetworks.entrySet()) {
            pw.println(entry.getKey() + ", scans to evict: " + entry.getValue());
        }
    }

    /** Set whether verbose logging is enabled. */
    public void enableVerboseLogging(boolean enabled) {
        mVerboseLoggingEnabled = enabled;
    }

    private class WakeupLockDataSource
            implements WakeupConfigStoreData.DataSource<Set<ScanResultMatchInfo>> {

        @Override
        public Set<ScanResultMatchInfo> getData() {
            return mLockedNetworks.keySet();
        }

        @Override
        public void setData(Set<ScanResultMatchInfo> data) {
            mLockedNetworks.clear();
            for (ScanResultMatchInfo network : data) {
                mLockedNetworks.put(network, CONSECUTIVE_MISSED_SCANS_REQUIRED_TO_EVICT);
            }
            // lock is considered initialized if loaded from store
            mIsInitialized = true;
        }
    }
}
