/*
 * Copyright (C) 2021 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.internal.telephony.uicc;

import android.annotation.RequiresFeature;
import android.content.Context;
import android.os.AsyncResult;
import android.os.Handler;
import android.os.Message;
import android.telephony.Rlog;
import android.telephony.TelephonyManager;
import android.text.TextUtils;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.telephony.CommandsInterface;
import com.android.internal.telephony.RadioInterfaceCapabilityController;
import com.android.internal.telephony.uicc.AdnCapacity;
import com.android.internal.telephony.uicc.IccConstants;

import java.util.ArrayList;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;


/**
 * Used to store SIM phonebook records.
 * <p/>
 * This will be {@link #INVALID} if either is the case:
 * <ol>
 *   <li>The device does not support
 * {@link android.telephony.TelephonyManager#CAPABILITY_SIM_PHONEBOOK_IN_MODEM}.</li>
 * </ol>
 * {@hide}
 */
@RequiresFeature(
        enforcement = "android.telephony.TelephonyManager#isRadioInterfaceCapabilitySupported",
        value = "TelephonyManager.CAPABILITY_SIM_PHONEBOOK_IN_MODEM")
public class SimPhonebookRecordCache extends Handler {
    // Instance Variables
    private String LOG_TAG = "SimPhonebookRecordCache";
    private static final boolean DBG = true;

    @VisibleForTesting
    static final boolean ENABLE_INFLATE_WITH_EMPTY_RECORDS = true;
    // Event Constants
    private static final int EVENT_PHONEBOOK_CHANGED = 1;
    private static final int EVENT_PHONEBOOK_RECORDS_RECEIVED = 2;
    private static final int EVENT_GET_PHONEBOOK_RECORDS_DONE = 3;
    private static final int EVENT_GET_PHONEBOOK_CAPACITY_DONE = 4;
    private static final int EVENT_UPDATE_PHONEBOOK_RECORD_DONE = 5;
    private static final int EVENT_SIM_REFRESH = 6;
    private static final int EVENT_GET_PHONEBOOK_RECORDS_RETRY = 7;

    private static final int MAX_RETRY_COUNT = 3;
    private static final int RETRY_INTERVAL = 3000; // 3S
    private static final int INVALID_RECORD_ID = -1;

    // member variables
    private final CommandsInterface mCi;
    private int mPhoneId;
    private Context mContext;

    // Presenting ADN capacity, including ADN, EMAIL ANR, and so on.
    private AtomicReference<AdnCapacity> mAdnCapacity = new AtomicReference<AdnCapacity>(null);
    private Object mReadLock = new Object();
    private final ConcurrentSkipListMap<Integer, AdnRecord> mSimPbRecords =
            new ConcurrentSkipListMap<Integer, AdnRecord>();
    private final List<UpdateRequest> mUpdateRequests =
            Collections.synchronizedList(new ArrayList<UpdateRequest>());
    // If true, clear the records in the cache and re-query from modem
    private AtomicBoolean mIsCacheInvalidated = new AtomicBoolean(false);
    private AtomicBoolean mIsRecordLoading = new AtomicBoolean(false);
    private AtomicBoolean mIsInRetry = new AtomicBoolean(false);
    private AtomicBoolean mIsInitialized = new AtomicBoolean(false);

    // People waiting for SIM phonebook records to be loaded
    ArrayList<Message> mAdnLoadingWaiters = new ArrayList<Message>();
    /**
     * The manual update from upper layer will result in notifying SIM phonebook changed,
     * leading to fetch the Adn capacity, then whether to need to reload phonebook records
     * is a problem. the SIM phoneback changed shall follow by updating record done, so that
     * uses this flag to avoid unnecessary loading.
     */
    boolean mIsUpdateDone = false;

    public SimPhonebookRecordCache(Context context, int phoneId, CommandsInterface ci) {
        mCi = ci;
        mPhoneId = phoneId;
        mContext = context;
        LOG_TAG += "[" + phoneId + "]";
        mCi.registerForSimPhonebookChanged(this, EVENT_PHONEBOOK_CHANGED, null);
        mCi.registerForIccRefresh(this, EVENT_SIM_REFRESH, null);
        mCi.registerForSimPhonebookRecordsReceived(this, EVENT_PHONEBOOK_RECORDS_RECEIVED, null);
    }

    /**
     * This is recommended to use in work thread like IccPhoneBookInterfaceManager
     * because it can't block main thread.
     * @return true if this feature is supported
     */
    public boolean isEnabled() {
        boolean isEnabled = RadioInterfaceCapabilityController
                .getInstance()
                .getCapabilities()
                .contains(TelephonyManager.CAPABILITY_SIM_PHONEBOOK_IN_MODEM);
        return mIsInitialized.get() || isEnabled;
    }

    public void dispose() {
        reset();
        mCi.unregisterForSimPhonebookChanged(this);
        mCi.unregisterForIccRefresh(this);
        mCi.unregisterForSimPhonebookRecordsReceived(this);
    }

    private void reset() {
        mAdnCapacity.set(null);
        mSimPbRecords.clear();
        mIsCacheInvalidated.set(false);
        mIsRecordLoading.set(false);
        mIsInRetry.set(false);
        mIsInitialized.set(false);
        mIsUpdateDone = false;
    }

    private void sendErrorResponse(Message response, String errString) {
        if (response != null) {
            Exception e = new RuntimeException(errString);
            AsyncResult.forMessage(response).exception = e;
            response.sendToTarget();
        }
    }

    private void notifyAndClearWaiters() {
        synchronized (mReadLock) {
            for (Message response : mAdnLoadingWaiters){
                if (response != null) {
                    List<AdnRecord> result =
                            new ArrayList<AdnRecord>(mSimPbRecords.values());
                    AsyncResult.forMessage(response, result, null);
                    response.sendToTarget();
                }
            }
            mAdnLoadingWaiters.clear();
        }
    }

    private void sendResponsesToWaitersWithError() {
        synchronized (mReadLock) {
            mReadLock.notify();

            for (Message response : mAdnLoadingWaiters) {
                sendErrorResponse(response, "Query adn record failed");
            }
            mAdnLoadingWaiters.clear();
        }
    }

    private void getSimPhonebookCapacity() {
        logd("Start to getSimPhonebookCapacity");
        mCi.getSimPhonebookCapacity(obtainMessage(EVENT_GET_PHONEBOOK_CAPACITY_DONE));
    }

    public AdnCapacity getAdnCapacity() {
        return mAdnCapacity.get();
    }

    private void fillCache() {
        synchronized (mReadLock) {
            fillCacheWithoutWaiting();
            try {
                mReadLock.wait();
            } catch (InterruptedException e) {
                loge("Interrupted Exception in queryAdnRecord");
            }
        }
    }

    private void fillCacheWithoutWaiting() {
        logd("Start to queryAdnRecord");
        if (mIsRecordLoading.compareAndSet(false, true)) {
            mCi.getSimPhonebookRecords(obtainMessage(EVENT_GET_PHONEBOOK_RECORDS_DONE));
        } else {
            logd("The loading is ongoing");
        }
    }

    public void requestLoadAllPbRecords(Message response) {
        if (response == null && !mIsInitialized.get()) {
            logd("Try to enforce flushing cache");
            fillCacheWithoutWaiting();
            return;
        }

        synchronized (mReadLock) {
            mAdnLoadingWaiters.add(response);
            final int pendingSize = mAdnLoadingWaiters.size();
            final boolean isCapacityInvalid = isAdnCapacityInvalid();
            if (isCapacityInvalid) {
                getSimPhonebookCapacity();
            }
            if (pendingSize > 1 || mIsInRetry.get()
                    || !mIsInitialized.get() || isCapacityInvalid) {
                logd("Add to the pending list as pending size = "
                        + pendingSize + " is retrying = " + mIsInRetry.get()
                        + " IsInitialized = " + mIsInitialized.get());
                return;
            }
        }
        if (!mIsRecordLoading.get() && !mIsInRetry.get()) {
            logd("ADN cache has already filled in");
            if (!mIsCacheInvalidated.get()) {
                notifyAndClearWaiters();
                return;
            }
        }
        fillCache();
    }

    private boolean isAdnCapacityInvalid() {
        return getAdnCapacity() == null || !getAdnCapacity().isSimValid();
    }

    @VisibleForTesting
    public boolean isLoading() {
        return mIsRecordLoading.get();
    }

    @VisibleForTesting
    public List<AdnRecord> getAdnRecords() {
        return mSimPbRecords.values().stream().collect(Collectors.toList());
    }

    @VisibleForTesting
    public void clear() {
        if (!ENABLE_INFLATE_WITH_EMPTY_RECORDS) {
            mSimPbRecords.clear();
        }
    }

    private void notifyAdnLoadingWaiters() {
        synchronized (mReadLock) {
            mReadLock.notify();
        }
        notifyAndClearWaiters();
    }

    public void updateSimPbAdnByRecordId(int recordId, AdnRecord newAdn, Message response) {
        if (newAdn == null) {
            sendErrorResponse(response, "There is an invalid new Adn for update");
            return;
        }
        boolean found = mSimPbRecords.containsKey(recordId);
        if (!found) {
            sendErrorResponse(response, "There is an invalid old Adn for update");
            return;
        }
        updateSimPhonebookByNewAdn(recordId, newAdn, response);
    }

    public void updateSimPbAdnBySearch(AdnRecord oldAdn, AdnRecord newAdn, Message response) {
        if (newAdn == null) {
            sendErrorResponse(response, "There is an invalid new Adn for update");
            return;
        }

        int recordId = INVALID_RECORD_ID; // The ID isn't specified by caller

        if (oldAdn != null && !oldAdn.isEmpty()) {
            for(AdnRecord adn : mSimPbRecords.values()) {
                if (oldAdn.isEqual(adn)) {
                    recordId = adn.getRecId();
                    break;
                }
            }
        }
        if (recordId == INVALID_RECORD_ID
                && mAdnCapacity.get() != null && mAdnCapacity.get().isSimFull()) {
            sendErrorResponse(response, "SIM Phonebook record is full");
            return;
        }

        updateSimPhonebookByNewAdn(recordId, newAdn, response);
    }

    private void updateSimPhonebookByNewAdn(int recordId, AdnRecord newAdn, Message response) {
        logd("update sim contact for record ID = " + recordId);
        final int updatingRecordId = recordId == INVALID_RECORD_ID ? 0 : recordId;
        SimPhonebookRecord updateAdn = new SimPhonebookRecord.Builder()
                .setRecordId(updatingRecordId)
                .setAlphaTag(newAdn.getAlphaTag())
                .setNumber(newAdn.getNumber())
                .setEmails(newAdn.getEmails())
                .setAdditionalNumbers(newAdn.getAdditionalNumbers())
                .build();
        UpdateRequest updateRequest = new UpdateRequest(recordId, newAdn, updateAdn, response);
        mUpdateRequests.add(updateRequest);
        final boolean isCapacityInvalid = isAdnCapacityInvalid();
        if (isCapacityInvalid) {
            getSimPhonebookCapacity();
        }
        if (mIsRecordLoading.get() || mIsInRetry.get() || mUpdateRequests.size() > 1
                || !mIsInitialized.get() || isCapacityInvalid) {
            logd("It is pending on update as " + " mIsRecordLoading = " + mIsRecordLoading.get()
                    + " mIsInRetry = " + mIsInRetry.get() + " pending size = "
                    + mUpdateRequests.size() + " mIsInitialized = " + mIsInitialized.get());
            return;
        }

        updateSimPhonebook(updateRequest);
    }

    private void updateSimPhonebook(UpdateRequest request) {
        logd("update Sim phonebook");
        mCi.updateSimPhonebookRecord(request.phonebookRecord,
                obtainMessage(EVENT_UPDATE_PHONEBOOK_RECORD_DONE, request));
    }

    @Override
    public void handleMessage(Message msg) {
        AsyncResult ar;
        switch(msg.what) {
            case EVENT_PHONEBOOK_CHANGED:
                logd("EVENT_PHONEBOOK_CHANGED");
                handlePhonebookChanged();
                break;
            case EVENT_GET_PHONEBOOK_RECORDS_DONE:
                logd("EVENT_GET_PHONEBOOK_RECORDS_DONE");
                ar = (AsyncResult)msg.obj;
                if (ar != null && ar.exception != null) {
                    loge("Failed to gain phonebook records");
                    invalidateSimPbCache();
                    if (!mIsInRetry.get()) {
                        sendGettingPhonebookRecordsRetry(0);
                    }
                }
                break;
            case EVENT_GET_PHONEBOOK_CAPACITY_DONE:
                logd("EVENT_GET_PHONEBOOK_CAPACITY_DONE");
                ar = (AsyncResult)msg.obj;
                if (ar != null && ar.exception == null) {
                    AdnCapacity capacity = (AdnCapacity)ar.result;
                    handlePhonebookCapacityChanged(capacity);
                } else {
                    if (!isAdnCapacityInvalid()) {
                        mAdnCapacity.set(new AdnCapacity());
                    }
                    invalidateSimPbCache();
                }
                break;
            case EVENT_PHONEBOOK_RECORDS_RECEIVED:
                logd("EVENT_PHONEBOOK_RECORDS_RECEIVED");
                ar = (AsyncResult)msg.obj;
                if (ar.exception != null) {
                    loge("Unexpected exception happened");
                    ar.result = null;
                }

                handlePhonebookRecordReceived((ReceivedPhonebookRecords)(ar.result));
                break;
            case EVENT_UPDATE_PHONEBOOK_RECORD_DONE:
                logd("EVENT_UPDATE_PHONEBOOK_RECORD_DONE");
                ar = (AsyncResult)msg.obj;
                handleUpdatePhonebookRecordDone(ar);
                break;
            case EVENT_SIM_REFRESH:
                logd("EVENT_SIM_REFRESH");
                ar = (AsyncResult)msg.obj;
                if (ar.exception == null) {
                    handleSimRefresh((IccRefreshResponse)ar.result);
                } else {
                    logd("SIM refresh Exception: " + ar.exception);
                }
                break;
            case EVENT_GET_PHONEBOOK_RECORDS_RETRY:
                int retryCount = msg.arg1;
                logd("EVENT_GET_PHONEBOOK_RECORDS_RETRY cnt = " + retryCount);
                if (retryCount < MAX_RETRY_COUNT) {
                    mIsRecordLoading.set(false);
                    fillCacheWithoutWaiting();
                    sendGettingPhonebookRecordsRetry(++retryCount);
                } else {
                    responseToWaitersWithErrorOrSuccess(false);
                }
                break;
            default:
                loge("Unexpected event: " + msg.what);
        }

    }

    private void responseToWaitersWithErrorOrSuccess(boolean success) {
        logd("responseToWaitersWithErrorOrSuccess success = " + success);
        mIsRecordLoading.set(false);
        mIsInRetry.set(false);
        if (success) {
            notifyAdnLoadingWaiters();
        } else {
            sendResponsesToWaitersWithError();

        }
        tryFireUpdatePendingList();
    }

    private void handlePhonebookChanged() {
        if (mUpdateRequests.isEmpty()) {
            // If this event is received, means this feature is supported.
            getSimPhonebookCapacity();
        } else {
            logd("Do nothing in the midst of multiple update");
        }
    }

    private void handlePhonebookCapacityChanged(AdnCapacity newCapacity) {
        AdnCapacity oldCapacity = mAdnCapacity.get();
        if (newCapacity == null) {
            newCapacity = new AdnCapacity();
        }
        mAdnCapacity.set(newCapacity);
        if (oldCapacity == null && newCapacity != null) {
            inflateWithEmptyRecords(newCapacity);
            if (!newCapacity.isSimEmpty()){
                mIsCacheInvalidated.set(true);
                fillCacheWithoutWaiting();
            } else if (newCapacity.isSimValid()) {
                notifyAdnLoadingWaiters();
                tryFireUpdatePendingList();
            } else {
                notifyAdnLoadingWaiters();
                logd("ADN capacity is invalid");
            }
            mIsInitialized.set(true); // Let's say the whole process is ready
        } else {
            // There is nothing from PB, so notify waiters directly if any
            if (newCapacity.isSimValid() && newCapacity.isSimEmpty()) {
                mIsCacheInvalidated.set(false);
                notifyAdnLoadingWaiters();
                tryFireUpdatePendingList();
            } else if (!newCapacity.isSimValid()) {
                mIsCacheInvalidated.set(false);
                notifyAdnLoadingWaiters();
            } else if (!mIsUpdateDone && !newCapacity.isSimEmpty()) {
                invalidateSimPbCache();
                fillCacheWithoutWaiting();
            }
            mIsUpdateDone = false;
        }
    }

    private void inflateWithEmptyRecords(AdnCapacity capacity) {
        if (ENABLE_INFLATE_WITH_EMPTY_RECORDS) {
            logd("inflateWithEmptyRecords");
            if (capacity != null && mSimPbRecords.isEmpty()) {
                for (int i = 1; i <= capacity.getMaxAdnCount(); i++) {
                    mSimPbRecords.putIfAbsent(i,
                            new AdnRecord(IccConstants.EF_ADN, i, null, null, null, null));
                }
            }
        }
    }

    private void handlePhonebookRecordReceived(ReceivedPhonebookRecords records) {
        if (records != null) {
            if (records.isOk()) {
                logd("Partial data is received");
                populateAdnRecords(records.getPhonebookRecords());
            } else if (records.isCompleted()) {
                logd("The whole loading process is finished");
                populateAdnRecords(records.getPhonebookRecords());
                mIsRecordLoading.set(false);
                mIsInRetry.set(false);
                mIsCacheInvalidated.set(false);
                notifyAdnLoadingWaiters();
                tryFireUpdatePendingList();
            } else if (records.isRetryNeeded() && !mIsInRetry.get()) {
                logd("Start to retry as aborted");
                sendGettingPhonebookRecordsRetry(0);
            } else {
                loge("Error happened");
                // Let's keep the stale data, in example of SIM getting removed during loading,
                // expects to finish the whole process.
                responseToWaitersWithErrorOrSuccess(true);
            }
        } else {
            loge("No records there");
            responseToWaitersWithErrorOrSuccess(true);
        }
    }

    private void handleUpdatePhonebookRecordDone(AsyncResult ar) {
        Exception e = null;
        UpdateRequest updateRequest = (UpdateRequest)ar.userObj;
        mIsUpdateDone = true;
        if (ar.exception == null) {
            int myRecordId = updateRequest.myRecordId;
            AdnRecord adn = updateRequest.adnRecord;
            int recordId = ((int[]) (ar.result))[0];
            logd("my record ID = " + myRecordId + " new record ID = " + recordId);
            if (myRecordId == INVALID_RECORD_ID || myRecordId == recordId) {
                if (!adn.isEmpty()) {
                    addOrChangeSimPbRecord(adn, recordId);
                } else {
                    deleteSimPbRecord(recordId);
                }
            } else {
                e = new RuntimeException("The record ID for update doesn't match");
            }

        } else {
            e = new RuntimeException("Update adn record failed", ar.exception);
        }

        if (mUpdateRequests.contains(updateRequest)) {
            mUpdateRequests.remove(updateRequest);
            updateRequest.responseResult(e);
        } else {
            loge("this update request isn't found");
        }
        tryFireUpdatePendingList();
    }

    private void tryFireUpdatePendingList() {
        if (!mUpdateRequests.isEmpty()) {
            updateSimPhonebook(mUpdateRequests.get(0));
        }
    }

    private void handleSimRefresh(IccRefreshResponse iccRefreshResponse) {
        if (iccRefreshResponse != null) {
            if (iccRefreshResponse.refreshResult == IccRefreshResponse.REFRESH_RESULT_FILE_UPDATE
                    && (iccRefreshResponse.efId == IccConstants.EF_PBR ||
                    iccRefreshResponse.efId == IccConstants.EF_ADN) ||
                    iccRefreshResponse.refreshResult == IccRefreshResponse.REFRESH_RESULT_INIT) {
                invalidateSimPbCache();
                getSimPhonebookCapacity();
            }
        } else {
            logd("IccRefreshResponse received is null");
        }
    }

    private void populateAdnRecords(List<SimPhonebookRecord> records) {
        if (records != null) {
            Map<Integer, AdnRecord> newRecords = records.stream().map(record -> {return
                    new AdnRecord(IccConstants.EF_ADN,
                    record.getRecordId(),
                    record.getAlphaTag(),
                    record.getNumber(),
                    record.getEmails(),
                    record.getAdditionalNumbers());})
                    .collect(Collectors.toMap(AdnRecord::getRecId, adn -> adn));
            mSimPbRecords.putAll(newRecords);
        }
    }

    private void sendGettingPhonebookRecordsRetry (int times) {
        if (hasMessages(EVENT_GET_PHONEBOOK_RECORDS_RETRY)) {
            removeMessages(EVENT_GET_PHONEBOOK_RECORDS_RETRY);
        }
        mIsInRetry.set(true);
        Message message = obtainMessage(EVENT_GET_PHONEBOOK_RECORDS_RETRY, 1, 0);
        sendMessageDelayed(message, RETRY_INTERVAL);
    }

    private void addOrChangeSimPbRecord(AdnRecord record, int recordId) {
        logd("Record number for the added or changed ADN is " + recordId);
        record.setRecId(recordId);
        if (ENABLE_INFLATE_WITH_EMPTY_RECORDS) {
            mSimPbRecords.replace(recordId, record);
        } else {
            mSimPbRecords.put(recordId, record);
        }
    }


    private void deleteSimPbRecord(int recordId) {
        logd("Record number for the deleted ADN is " + recordId);
        if (ENABLE_INFLATE_WITH_EMPTY_RECORDS) {
            mSimPbRecords.replace(recordId,
                    new AdnRecord(IccConstants.EF_ADN, recordId, null, null, null, null));
        } else {
            if (mSimPbRecords.containsKey(recordId)) {
                mSimPbRecords.remove(recordId);
            }
        }
    }

    private void invalidateSimPbCache() {
        logd("invalidateSimPbCache");
        mIsCacheInvalidated.set(true);
        if (ENABLE_INFLATE_WITH_EMPTY_RECORDS) {
            mSimPbRecords.replaceAll((k, v) ->
                    new AdnRecord(IccConstants.EF_ADN, k, null, null, null, null));
        } else {
            mSimPbRecords.clear();
        }
    }

    private void logd(String msg) {
        if (DBG) {
            Rlog.d(LOG_TAG, msg);
        }
    }

    private void loge(String msg) {
        if (DBG) {
            Rlog.e(LOG_TAG, msg);
        }
    }

    private final static class UpdateRequest {
        private int myRecordId;
        private Message response;
        private AdnRecord adnRecord;
        private SimPhonebookRecord phonebookRecord;

        UpdateRequest(int recordId, AdnRecord record, SimPhonebookRecord phonebookRecord,
                Message response) {
            this.myRecordId = recordId;
            this.adnRecord = record;
            this.phonebookRecord = phonebookRecord;
            this.response = response;
        }

        void responseResult(Exception e) {
            if (response != null) {
                AsyncResult.forMessage(response, null, e);
                response.sendToTarget();
            }
        }
    }
}
