package org.altbeacon.beacon.service;

import android.app.job.JobInfo;
import android.app.job.JobScheduler;
import android.bluetooth.le.ScanResult;
import android.content.ComponentName;
import android.content.Context;
import android.os.Build;
import android.os.PersistableBundle;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;

import org.altbeacon.beacon.BeaconLocalBroadcastProcessor;
import org.altbeacon.beacon.BeaconManager;
import org.altbeacon.beacon.logging.LogManager;

import java.util.ArrayList;
import java.util.List;

/**
 * Schedules two types of ScanJobs:
 *  1. Periodic, which are set to go every scanPeriod+betweenScanPeriod
 *  2. Immediate, which go right now.
 *
 *  Immediate ScanJobs are used when the app is in the foreground and wants to get immediate results
 *  or when beacons have been detected with background scan filters and delivered via Intents and
 *  a scan needs to run in a timely manner to collect data about those beacons known to be newly
 *  in the vicinity despite the app being in the background.
 *
 * Created by dyoung on 6/7/17.
 * @hide
 */
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public class ScanJobScheduler {
    private static final String TAG = ScanJobScheduler.class.getSimpleName();
    private static final Object SINGLETON_LOCK = new Object();
    private static final long MIN_MILLIS_BETWEEN_SCAN_JOB_SCHEDULING = 10000L;
    @Nullable
    private static volatile ScanJobScheduler sInstance = null;
    @NonNull
    private Long mScanJobScheduleTime = 0L;
    @NonNull
    private List<ScanResult> mBackgroundScanResultQueue = new ArrayList<>();
    @Nullable
    private BeaconLocalBroadcastProcessor mBeaconNotificationProcessor;
    @NonNull
    private boolean mBackgroundScanJobFirstRun = true;

    @NonNull
    public static ScanJobScheduler getInstance() {
        ScanJobScheduler instance = sInstance;
        if (instance == null) {
            synchronized (SINGLETON_LOCK) {
                instance = sInstance;
                if (instance == null) {
                    sInstance = instance = new ScanJobScheduler();
                }
            }
        }
        return instance;
    }

    private ScanJobScheduler() {
    }

    void ensureNotificationProcessorSetup(Context context) {
        if (mBeaconNotificationProcessor == null) {
            mBeaconNotificationProcessor = new BeaconLocalBroadcastProcessor(context);
        }
        mBeaconNotificationProcessor.register();
    }

    /**
     * @return previoulsy queued scan results delivered in the background
     */
    List<ScanResult> dumpBackgroundScanResultQueue() {
        List<ScanResult> retval = mBackgroundScanResultQueue;
        mBackgroundScanResultQueue = new ArrayList<>();
        return retval;
    }

    private void applySettingsToScheduledJob(Context context, BeaconManager beaconManager, ScanState scanState) {
        scanState.applyChanges(beaconManager);
        LogManager.d(TAG, "Applying scan job settings with background mode "+scanState.getBackgroundMode());

        // if this is the first time we want to schedule a job and we are in background mode
        // trigger an immediate scan job in order to install the hw filter
        boolean startBackgroundImmediateScan = false;
        if (this.mBackgroundScanJobFirstRun && scanState.getBackgroundMode()) {
            LogManager.d(TAG, "This is the first time we schedule a job and we are in background, set immediate scan flag to true in order to trigger the HW filter install.");
            startBackgroundImmediateScan = true;
        }

        schedule(context, scanState, startBackgroundImmediateScan);
    }

    public void applySettingsToScheduledJob(Context context, BeaconManager beaconManager) {
        LogManager.d(TAG, "Applying settings to ScanJob");
        JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
        ScanState scanState = ScanState.restore(context);
        applySettingsToScheduledJob(context, beaconManager, scanState);
    }

    public void cancelSchedule(Context context) {
        JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
        jobScheduler.cancel(ScanJob.getImmediateScanJobId(context));
        jobScheduler.cancel(ScanJob.getPeriodicScanJobId(context));

        if (mBeaconNotificationProcessor != null) {
            mBeaconNotificationProcessor.unregister();
        }

        mBackgroundScanJobFirstRun = true;
    }

    public void scheduleAfterBackgroundWakeup(Context context, List<ScanResult> scanResults) {
        if (scanResults != null) {
            mBackgroundScanResultQueue.addAll(scanResults);
        }
        synchronized (this) {
            // We typically get a bunch of calls in a row here, separated by a few millis.  Only do this once.
            if (System.currentTimeMillis() - mScanJobScheduleTime > MIN_MILLIS_BETWEEN_SCAN_JOB_SCHEDULING) {
                LogManager.d(TAG, "scheduling an immediate scan job because last did "+(System.currentTimeMillis() - mScanJobScheduleTime)+"millis ago.");
                mScanJobScheduleTime = System.currentTimeMillis();
            }
            else {
                LogManager.d(TAG, "Not scheduling an immediate scan job because we just did recently.");
                return;
            }
        }
        ScanState scanState = ScanState.restore(context);
        schedule(context, scanState, true);
    }

    public void forceScheduleNextScan(Context context) {
        ScanState scanState = ScanState.restore(context);
        schedule(context, scanState, false);
    }

    private void schedule(Context context, ScanState scanState, boolean backgroundWakeup) {
        ensureNotificationProcessorSetup(context);

        long betweenScanPeriod = scanState.getScanJobIntervalMillis() - scanState.getScanJobRuntimeMillis();

        long millisToNextJobStart;
        if (backgroundWakeup) {
            LogManager.d(TAG, "We just woke up in the background based on a new scan result or first run of the app. Start scan job immediately.");
            millisToNextJobStart = 0;
        }
        else {
            if (betweenScanPeriod > 0) {
                // If we pause between scans, then we need to start scanning on a normalized time
                millisToNextJobStart = (SystemClock.elapsedRealtime() % scanState.getScanJobIntervalMillis());
            }
            else {
                millisToNextJobStart = 0;
            }

            if (millisToNextJobStart < 50) {
                // always wait a little bit to start scanning in case settings keep changing.
                // by user restarting settings and scanning.  50ms should be fine
                millisToNextJobStart = 50;
            }
        }

        JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);

        int monitoredAndRangedRegionCount = scanState.getMonitoringStatus().regions().size() + scanState.getRangedRegionState().size();
        if (monitoredAndRangedRegionCount > 0) {
            if (backgroundWakeup || !scanState.getBackgroundMode()) {
                // If we are in the foreground, and we want to start a scan soon, we will schedule an
                // immediate job
                if (millisToNextJobStart < scanState.getScanJobIntervalMillis() - 50) {
                    // If the next time we want to scan is less than 50ms from the periodic scan cycle, then]
                    // we schedule it for that specific time.
                    LogManager.d(TAG, "Scheduling immediate ScanJob to run in "+millisToNextJobStart+" millis");
                    JobInfo immediateJob = new JobInfo.Builder(ScanJob.getImmediateScanJobId(context), new ComponentName(context, ScanJob.class))
                            .setPersisted(true) // This makes it restart after reboot
                            .setExtras(new PersistableBundle())
                            .setMinimumLatency(millisToNextJobStart)
                            .setOverrideDeadline(millisToNextJobStart).build();
                    int error = jobScheduler.schedule(immediateJob);
                    if (error < 0) {
                        LogManager.e(TAG, "Failed to schedule an immediate scan job.  Beacons will not be detected. Error: "+error);
                    } else if (this.mBackgroundScanJobFirstRun) {
                        LogManager.d(TAG, "First immediate scan job scheduled successful, change the flag to false.");
                        this.mBackgroundScanJobFirstRun = false;
                    }
                } else {
                    LogManager.d(TAG, "Not scheduling immediate scan, assuming periodic is about to run");
                }
            }
            else {
                LogManager.d(TAG, "Not scheduling an immediate scan because we are in background mode.   Cancelling existing immediate ScanJob.");
                jobScheduler.cancel(ScanJob.getImmediateScanJobId(context));
            }

            JobInfo.Builder periodicJobBuilder = new JobInfo.Builder(ScanJob.getPeriodicScanJobId(context), new ComponentName(context, ScanJob.class))
                    .setPersisted(true) // This makes it restart after reboot
                    .setExtras(new PersistableBundle());

            if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                // ON Android N+ we specify a tolerance of 0ms (capped at 5% by the OS) to ensure
                // our scans happen within 5% of the schduled time.
                periodicJobBuilder.setPeriodic(scanState.getScanJobIntervalMillis(), 0L).build();
            }
            else {
                periodicJobBuilder.setPeriodic(scanState.getScanJobIntervalMillis()).build();
            }
            final JobInfo jobInfo = periodicJobBuilder.build();
            LogManager.d(TAG, "Scheduling periodic ScanJob " + jobInfo + " to run every "+scanState.getScanJobIntervalMillis()+" millis");
            int error = jobScheduler.schedule(jobInfo);
            if (error < 0) {
                LogManager.e(TAG, "Failed to schedule a periodic scan job.  Beacons will not be detected. Error: "+error);
            }

        }
        else {
            LogManager.d(TAG, "We are not monitoring or ranging any regions.  We are going to cancel all scan jobs.");
            jobScheduler.cancel(ScanJob.getImmediateScanJobId(context));
            jobScheduler.cancel(ScanJob.getPeriodicScanJobId(context));
        }
    }
}