package com.doist.jobschedulercompat;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;

import com.doist.jobschedulercompat.job.JobStatus;
import com.doist.jobschedulercompat.job.JobStore;
import com.doist.jobschedulercompat.scheduler.Scheduler;
import com.doist.jobschedulercompat.scheduler.alarm.AlarmScheduler;
import com.doist.jobschedulercompat.scheduler.gcm.GcmScheduler;
import com.doist.jobschedulercompat.scheduler.jobscheduler.JobSchedulerSchedulerV21;
import com.doist.jobschedulercompat.scheduler.jobscheduler.JobSchedulerSchedulerV24;
import com.doist.jobschedulercompat.scheduler.jobscheduler.JobSchedulerSchedulerV26;
import com.doist.jobschedulercompat.scheduler.jobscheduler.JobSchedulerSchedulerV28;

import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Build;
import android.os.SystemClock;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;

/** @see android.app.job.JobScheduler */
public class JobScheduler {
    /** @see android.app.job.JobScheduler#RESULT_SUCCESS */
    public static final int RESULT_FAILURE = 0;
    /** @see android.app.job.JobScheduler#RESULT_FAILURE */
    public static final int RESULT_SUCCESS = 1;

    static final int MAX_JOBS = 100;

    final Map<String, Scheduler> schedulers = new HashMap<>();

    @SuppressLint("StaticFieldLeak")
    private static JobScheduler instance;

    public static synchronized JobScheduler get(Context context) {
        if (instance == null) {
            instance = new JobScheduler(context);
        }
        return instance;
    }

    private final Context context;
    private final JobStore jobStore;

    private JobScheduler(Context context) {
        this.context = context.getApplicationContext();
        this.jobStore = JobStore.get(context);
    }

    /** @see android.app.job.JobScheduler#schedule(android.app.job.JobInfo) */
    public int schedule(JobInfo job) {
        synchronized (JobStore.LOCK) {
            if (jobStore.size() > MAX_JOBS) {
                throw new IllegalStateException("Apps may not schedule more than " + MAX_JOBS + " distinct jobs");
            }
            Scheduler scheduler = getSchedulerForJob(context, job);
            jobStore.add(JobStatus.createFromJobInfo(job, scheduler.getTag()));
            return scheduler.schedule(job);
        }
    }

    /** @see android.app.job.JobScheduler#cancel(int) */
    public void cancel(int jobId) {
        synchronized (JobStore.LOCK) {
            JobStatus jobStatus = jobStore.getJob(jobId);
            if (jobStatus != null) {
                jobStore.remove(jobId);
                getSchedulerForTag(context, jobStatus.getSchedulerTag()).cancel(jobId);
            }
        }
    }

    /** @see android.app.job.JobScheduler#cancelAll() */
    public void cancelAll() {
        synchronized (JobStore.LOCK) {
            Set<String> tags = new HashSet<>();
            for (JobStatus jobStatus : jobStore.getJobs()) {
                tags.add(jobStatus.getSchedulerTag());
            }
            jobStore.clear();
            for (String tag : tags) {
                getSchedulerForTag(context, tag).cancelAll();
            }
        }
    }

    /** @see android.app.job.JobScheduler#getAllPendingJobs() */
    @NonNull
    public List<JobInfo> getAllPendingJobs() {
        synchronized (JobStore.LOCK) {
            List<JobStatus> jobStatuses = jobStore.getJobs();
            List<JobInfo> result = new ArrayList<>(jobStatuses.size());
            for (JobStatus jobStatus : jobStatuses) {
                result.add(jobStatus.getJob());
            }
            return result;
        }
    }

    /** @see android.app.job.JobScheduler#getPendingJob(int) */
    @Nullable
    public JobInfo getPendingJob(int jobId) {
        synchronized (JobStore.LOCK) {
            JobStatus jobStatus = jobStore.getJob(jobId);
            return jobStatus != null ? jobStatus.getJob() : null;
        }
    }

    /**
     * Notify the scheduler that a job finished executing.
     *
     * Handle scheduler changes by cancelling it in the old scheduler and scheduling it in the new scheduler.
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public void onJobCompleted(int jobId, boolean needsReschedule) {
        synchronized (JobStore.LOCK) {
            JobStatus jobStatus = jobStore.getJob(jobId);
            if (jobStatus != null) {
                jobStore.remove(jobId);
                if (needsReschedule) {
                    jobStore.add(getRescheduleJobForFailure(jobStatus));
                } else if (jobStatus.isPeriodic()) {
                    jobStore.add(getRescheduleJobForPeriodic(jobStatus));
                }
                getSchedulerForTag(context, jobStatus.getSchedulerTag()).onJobCompleted(jobId, needsReschedule);
            }
        }
    }

    /** Similar to com.android.server.job.JobSchedulerService#getRescheduleJobForFailureLocked(JobStatus). */
    private JobStatus getRescheduleJobForFailure(JobStatus failureToReschedule) {
        final long elapsedNowMillis = SystemClock.elapsedRealtime();
        final JobInfo job = failureToReschedule.getJob();
        final long initialBackoffMillis = job.getInitialBackoffMillis();
        final int backoffAttempts = failureToReschedule.getNumFailures() + 1;
        long delayMillis;
        switch (job.getBackoffPolicy()) {
            case JobInfo.BACKOFF_POLICY_LINEAR:
                delayMillis = initialBackoffMillis * backoffAttempts;
                break;

            case JobInfo.BACKOFF_POLICY_EXPONENTIAL:
            default:
                delayMillis = (long) Math.scalb(initialBackoffMillis, backoffAttempts - 1);
                break;
        }
        delayMillis = Math.min(delayMillis, JobInfo.MAX_BACKOFF_DELAY_MILLIS);

        JobStatus newJob = new JobStatus(
                failureToReschedule.getJob(), failureToReschedule.getSchedulerTag(), backoffAttempts,
                elapsedNowMillis + delayMillis, JobStatus.NO_LATEST_RUNTIME);

        getSchedulerForTag(context, newJob.getSchedulerTag()).onJobRescheduled(newJob, failureToReschedule);

        return newJob;
    }

    /** Similar to com.android.server.job.JobSchedulerService#getRescheduleJobForPeriodic(JobStatus). */
    private JobStatus getRescheduleJobForPeriodic(JobStatus periodicToReschedule) {
        final long elapsedNowMillis = SystemClock.elapsedRealtime();
        // Compute how much of the period is remaining.
        long runEarly = 0L;
        // If this periodic was rescheduled it won't have a deadline.
        if (periodicToReschedule.hasDeadlineConstraint()) {
            runEarly = Math.max(periodicToReschedule.getLatestRunTimeElapsed() - elapsedNowMillis, 0L);
        }
        final long newEarliestRunTimeElapsed = elapsedNowMillis + runEarly;
        final long period = periodicToReschedule.getJob().getIntervalMillis();
        final long newLatestRuntimeElapsed = newEarliestRunTimeElapsed + period;

        return new JobStatus(
                periodicToReschedule.getJob(), periodicToReschedule.getSchedulerTag(), 0 /* backoffAttempt */,
                newEarliestRunTimeElapsed, newLatestRuntimeElapsed);
    }

    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public List<JobStatus> getJobsByScheduler(String scheduler) {
        synchronized (JobStore.LOCK) {
            return jobStore.getJobsByScheduler(scheduler);
        }
    }

    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public JobStatus getJob(int jobId) {
        synchronized (JobStore.LOCK) {
            return jobStore.getJob(jobId);
        }
    }

    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public void addJob(JobStatus jobStatus) {
        synchronized (JobStore.LOCK) {
            jobStore.add(jobStatus);
        }
    }

    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public void removeJob(int jobId) {
        synchronized (JobStore.LOCK) {
            jobStore.remove(jobId);
        }
    }

    @RestrictTo(RestrictTo.Scope.LIBRARY)
    Scheduler getSchedulerForJob(Context context, JobInfo job) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            return getSchedulerForTag(context, JobSchedulerSchedulerV28.TAG);
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            return getSchedulerForTag(context, JobSchedulerSchedulerV26.TAG);
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
                && job.getNetworkType() != JobInfo.NETWORK_TYPE_CELLULAR
                && !job.isRequireBatteryNotLow()
                && !job.isRequireStorageNotLow()) {
            return getSchedulerForTag(context, JobSchedulerSchedulerV24.TAG);
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
                && (!job.isPeriodic() || job.getFlexMillis() >= job.getIntervalMillis())
                && job.getNetworkType() != JobInfo.NETWORK_TYPE_NOT_ROAMING
                && job.getNetworkType() != JobInfo.NETWORK_TYPE_CELLULAR
                && job.getTriggerContentUris() == null
                && !job.isRequireBatteryNotLow()
                && !job.isRequireStorageNotLow()) {
            return getSchedulerForTag(context, JobSchedulerSchedulerV21.TAG);
        }

        boolean gcmAvailable;
        try {
            gcmAvailable = Class.forName("com.google.android.gms.gcm.GcmNetworkManager") != null
                    && GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
                    == ConnectionResult.SUCCESS;
        } catch (Throwable ignored) {
            gcmAvailable = false;
        }
        if (gcmAvailable
                && job.getNetworkType() != JobInfo.NETWORK_TYPE_NOT_ROAMING
                && job.getNetworkType() != JobInfo.NETWORK_TYPE_CELLULAR
                && !job.isRequireBatteryNotLow()
                && !job.isRequireStorageNotLow()) {
            return getSchedulerForTag(context, GcmScheduler.TAG);
        }

        return getSchedulerForTag(context, AlarmScheduler.TAG);
    }

    @RestrictTo(RestrictTo.Scope.LIBRARY)
    Scheduler getSchedulerForTag(Context context, String tag) {
        Scheduler scheduler = schedulers.get(tag);
        if (scheduler == null) {
            switch (tag) {
                case JobSchedulerSchedulerV28.TAG:
                    scheduler = new JobSchedulerSchedulerV28(context);
                    break;
                case JobSchedulerSchedulerV26.TAG:
                    scheduler = new JobSchedulerSchedulerV26(context);
                    break;
                case JobSchedulerSchedulerV24.TAG:
                    scheduler = new JobSchedulerSchedulerV24(context);
                    break;
                case JobSchedulerSchedulerV21.TAG:
                    scheduler = new JobSchedulerSchedulerV21(context);
                    break;
                case GcmScheduler.TAG:
                    scheduler = new GcmScheduler(context);
                    break;
                case AlarmScheduler.TAG:
                    scheduler = new AlarmScheduler(context);
                    break;
                default:
                    throw new IllegalArgumentException("Missing scheduler for tag " + tag);
            }
            schedulers.put(tag, scheduler);
        }
        return scheduler;
    }
}