/*
 * Copyright 2015 Kevin Liu
 *
 * 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.github.airk.trigger;

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.os.Binder;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.PowerManager;
import android.os.SystemClock;
import android.util.Log;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;

/**
 * Trigger loop, all jobs finally get in here and be managed, checked and executed.
 */
public final class TriggerLoop extends Service {
    static final String CONDITION_DATA = "condition_data";
    static final String PROTOCOL_KEY = "protocol_key";
    static final String DEVICE_KEY = "device_key";
    static final String STATUS_CHANGED = "status_changed";
    static final int PROTOCOL_CODE = 0x991;
    static final int THREAD_POOL_SIZE = Runtime.getRuntime().availableProcessors() * 2 + 1;

    private static final String TAG = "TriggerLoop";
    private static final String DEADLINE_BROADCAST = "com.github.airk.trigger.broadcast.deadline";
    private static final String JOB_PERSIST_DIR = "job_persist";
    private static final String JOB_BACKUP_DIR = "job_backup";

    private ConcurrentHashMap<String, Job> jobSet;
    private ConcurrentHashMap<String, BroadcastReceiver> receivers;
    private ConcurrentHashMap<String, Long> jobHappens;
    private TriggerBinder binder;
    private CheckHandler checker;
    private ExecutorService executor;
    private Handler mainHandler;
    private AlarmManager alarmManager;
    private Handler shortDeadlineHandler;
    private DeadlineCheck deadlineCheck;
    private PowerManager.WakeLock wakeLock;
    private DeviceStatus sDeviceStatus;
    private HandlerThread handlerThread;

    static Intent newIntent(Context context) {
        Intent intent = new Intent(context, TriggerLoop.class);
        intent.putExtra(PROTOCOL_KEY, PROTOCOL_CODE);
        return intent;
    }

    static Intent newIntent(Context context, ConditionDesc condition) {
        Intent data = newIntent(context);
        data.putExtra(CONDITION_DATA, condition);
        return data;
    }

    static Intent deviceOn(Context context) {
        Intent data = newIntent(context);
        data.putExtra(DEVICE_KEY, true);
        return data;
    }

    static Intent deviceStatusChanged(Context context, String which) {
        Intent data = newIntent(context);
        data.putExtra(STATUS_CHANGED, which);
        return data;
    }

    @Override
    public void onCreate() {
        super.onCreate();
        jobSet = new ConcurrentHashMap<>();
        receivers = new ConcurrentHashMap<>();
        jobHappens = new ConcurrentHashMap<>();
        binder = new TriggerBinder();
        executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE, new TriggerWorkerFactory());
        mainHandler = new Handler(Looper.getMainLooper());
        alarmManager = (AlarmManager) getSystemService(ALARM_SERVICE);
        shortDeadlineHandler = new Handler();
        deadlineCheck = new DeadlineCheck();
        sDeviceStatus = DeviceStatus.get(this);
        registerReceiver(deadlineCheck, new IntentFilter(DEADLINE_BROADCAST));
        PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
        int granted = checkCallingOrSelfPermission("android.permission.WAKE_LOCK");
        if (granted == PackageManager.PERMISSION_GRANTED) {
            wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
        } else {
            wakeLock = null;
        }
        handlerThread = new HandlerThread("Trigger-HandlerThread");
        handlerThread.start();
        checker = new CheckHandler(handlerThread.getLooper());
        mayRecoverJobsFromFile();
    }

    private void mayRecoverJobsFromFile() {
        File recoverDir = new File(getFilesDir(), JOB_BACKUP_DIR);
        if (!recoverDir.exists())
            return;
        if (recoverDir.listFiles() == null)
            return;
        for (File file : recoverDir.listFiles()) {
            Job job = null;
            try {
                job = Job.createJobFromPersistInfo(Job.JobInfo.readFromFile(file));
            } catch (IOException e) {
                e.printStackTrace();
            }
            file.delete();
            if (job != null) {
                addJob(job, true);
            }
        }
    }

    private void tryCreateBackup(Job job) {
        if (!job.canBePersist)
            return;
        File recoverDir = new File(getFilesDir(), JOB_BACKUP_DIR);
        if (!recoverDir.exists()) {
            recoverDir.mkdirs();
        }
        try {
            job.jobInfo.writeToFile(recoverDir);
        } catch (IOException e) {
            e.printStackTrace();
        }
        //try to persist job that need valid after reboot
        if (!job.jobInfo.persistAfterReboot)
            return;
        final File dir = new File(getFilesDir(), JOB_PERSIST_DIR);
        if (!dir.exists()) {
            dir.mkdirs();
        }
        try {
            job.jobInfo.writeToFile(dir);
        } catch (IOException ignore) {
        }
    }

    private void deleteBackup(Job job) {
        File recoverDir = new File(getFilesDir(), JOB_BACKUP_DIR);
        if (!recoverDir.exists())
            return;
        job.jobInfo.tryDelete(recoverDir);
        final File dir = new File(getFilesDir(), JOB_PERSIST_DIR);
        if (!dir.exists()) {
            return;
        }
        if (!job.jobInfo.persistAfterReboot)
            return;
        job.jobInfo.tryDelete(dir);
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        sDeviceStatus.onDestroy();
        handlerThread.quit();
        unregisterReceiver(deadlineCheck);
    }

    private void tryAcquireLock() {
        if (wakeLock != null) {
            wakeLock.acquire();
        }
    }

    private void tryReleaseLock() {
        if (wakeLock != null) {
            wakeLock.release();
        }
    }

    void addJob(Job job, boolean mayTrigger) {
        if (job == null)
            return;
        if (jobSet.containsKey(job.jobInfo.identity))
            return;
        //avoid duplicate job
        if (job.jobInfo.persistAfterReboot) {
            for (String jk : jobSet.keySet()) {
                Job existJob = jobSet.get(jk);
                if (existJob.equals(job))
                    return;
            }
        }
        jobSet.put(job.jobInfo.identity, job);
        tryCreateBackup(job);
        //receiver handle
        for (Condition c : job.exConds) {
            if (!receivers.containsKey(c.getIdentify())) {
                IntentFilter filter = new IntentFilter();
                for (String act : c.getAction()) {
                    filter.addAction(act);
                }
                ReceiverInner rec = new ReceiverInner(c);
                registerReceiver(rec, filter);
                receivers.put(c.getIdentify(), rec);
            }
        }
        //can it happen now?
        if (mayTrigger) {
            if (job.condSatisfied.containsKey(Job.CHARGING_KEY)) {
                job.condSatisfied.put(Job.CHARGING_KEY, DeviceStatus.chargingConstraintSatisfied.get());
            }
            if (job.condSatisfied.containsKey(Job.NETWORK_TYPE_KEY)) {
                job.condSatisfied.put(Job.NETWORK_TYPE_KEY, DeviceStatus.networkTypeSatisfied(job.jobInfo.networkType));
            }
            if (job.condSatisfied.containsKey(Job.IDLE_DEVICE_KEY)) {
                job.condSatisfied.put(Job.IDLE_DEVICE_KEY, DeviceStatus.idleConstraintSatisfied.get());
            }
            if (checker.mayTriggerAfterCheck(job))
                return;
        }

        //deadline handle
        if (job.jobInfo.deadline != -1L) {
            long df = job.jobInfo.deadline - System.currentTimeMillis();
            if (df < 0) {
                checker.trigger(job);
                return;
            }
            if (df > 60 * 1000) {
                PendingIntent pi = PendingIntent.getBroadcast(this, 0, new Intent(DEADLINE_BROADCAST), 0);
                if (Build.VERSION.SDK_INT >= 19) {
                    alarmManager.setExact(AlarmManager.RTC_WAKEUP, job.jobInfo.deadline, pi);
                } else {
                    alarmManager.set(AlarmManager.RTC_WAKEUP, job.jobInfo.deadline, pi);
                }
                job.deadLineObj = pi;
            } else {
                Runnable r = new Runnable() {
                    @Override
                    public void run() {
                        checker.checkDeadline();
                    }
                };
                job.deadLineObj = r;
                shortDeadlineHandler.postDelayed(r, df);
            }
        }
    }

    void removeJob(String tag) {
        ArrayList<String> tobeRemoved = new ArrayList<>();
        for (String key : jobSet.keySet()) {
            Job job = jobSet.get(key);
            if (job.jobInfo.tag.equals(tag)) {
                tobeRemoved.add(key);
            }
        }
        for (String k : tobeRemoved) {
            removeOne(k);
        }
    }

    void removeOne(String key) {
        Job removed = null;
        if (jobSet.containsKey(key)) {
            removed = jobSet.remove(key);
        }
        if (removed == null)
            return;
        deleteBackup(removed);
        List<String> removedConds = new ArrayList<>();
        for (Receiver c : removed.exConds) {
            removedConds.add(c.getIdentify());
        }
        for (Map.Entry<String, Job> entry : jobSet.entrySet()) {
            for (Receiver c : entry.getValue().exConds) {
                if (removedConds.indexOf(c.getIdentify()) != -1) {
                    removedConds.remove(c.getIdentify());
                }
            }
        }
        for (String k : removedConds) {
            unregisterReceiver(receivers.get(k));
            receivers.remove(k);
        }
        //deadline handle
        if (removed.jobInfo.deadline != -1L) {
            if (removed.deadLineObj instanceof PendingIntent) {
                alarmManager.cancel((PendingIntent) removed.deadLineObj);
            } else if (removed.deadLineObj instanceof Runnable) {
                shortDeadlineHandler.removeCallbacks((Runnable) removed.deadLineObj);
            }
        }
    }

    void cleanUpAll() {
        checker.cleanup();
        for (Map.Entry<String, Job> entry : jobSet.entrySet()) {
            Job job = entry.getValue();
            job.resetConds();
            if (job.deadLineObj != null) {
                if (job.deadLineObj instanceof PendingIntent) {
                    alarmManager.cancel((PendingIntent) job.deadLineObj);
                } else if (job.deadLineObj instanceof Runnable) {
                    shortDeadlineHandler.removeCallbacks((Runnable) job.deadLineObj);
                }
            }
        }
        jobSet.clear();
        for (Map.Entry<String, BroadcastReceiver> entry : receivers.entrySet()) {
            BroadcastReceiver r = entry.getValue();
            unregisterReceiver(r);
        }
        receivers.clear();
        deleteDir(new File(getFilesDir(), JOB_BACKUP_DIR));
        deleteDir(new File(getFilesDir(), JOB_PERSIST_DIR));
    }

    private void deleteDir(File file) {
        if (!file.exists() || !file.isDirectory()) {
            return;
        }
        for (File f : file.listFiles()) {
            if (f.isDirectory()) {
                deleteDir(f);
            } else {
                f.delete();
            }
        }
        file.delete();
    }

    @Override
    public IBinder onBind(Intent intent) {
        return binder;
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent == null) {
            return START_STICKY;
        }
        if (!intent.hasExtra(PROTOCOL_KEY) || intent.getIntExtra(PROTOCOL_KEY, -1) != PROTOCOL_CODE) {
            throw new IllegalAccessError("TriggerLoop won't receive user command.");
        }
        if (intent.hasExtra(CONDITION_DATA)) {
            ConditionDesc condition = intent.getParcelableExtra(CONDITION_DATA);
            Log.d(TAG, condition.toString());
            checker.checkSatisfy(condition);
        } else if (intent.hasExtra(DEVICE_KEY)) {
            checker.checkDeviceOn();
        } else if (intent.hasExtra(STATUS_CHANGED)) {
            checker.checkStatusChanged(intent.getStringExtra(STATUS_CHANGED));
        }
        return START_STICKY;
    }

    private static class TriggerWorkerFactory implements ThreadFactory {
        static int counter = 0;

        @Override
        public Thread newThread(Runnable r) {
            return new Thread(r, "Trigger-Worker:" + ++counter);
        }

    }

    protected class TriggerBinder extends Binder {
        void schedule(Job job) {
            addJob(job, true);
        }

        void cancel(String tag) {
            removeJob(tag);
        }

        void removePersistJob(String tag) {
            checker.removePersistJobWithTag(tag);
        }

        void stopAndReset() {
            cleanUpAll();
        }

    }

    private class CheckHandler extends Handler {
        private final int MSG_SATISFY = 1;
        private final int MSG_DEADLINE = 2;
        private final int MSG_DEVICE_ON = 3;
        private final int MSG_STATUS_CHANGED = 4;
        private final int MSG_REMOVE_TAG_JOB = 5;

        public CheckHandler(Looper looper) {
            super(looper);
        }

        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MSG_SATISFY:
                    checkSatisfyImpl((ConditionDesc) msg.obj);
                    break;
                case MSG_DEADLINE:
                    checkDeadlineImpl();
                    break;
                case MSG_DEVICE_ON:
                    checkDeviceOnImpl();
                    break;
                case MSG_STATUS_CHANGED:
                    checkStatusChangedImpl((String) msg.obj);
                    break;
                case MSG_REMOVE_TAG_JOB:
                    removePersistJobWithTagImpl((String) msg.obj);
                    break;
                default:
                    break;
            }
        }

        private void checkSatisfyImpl(ConditionDesc cond) {
            tryAcquireLock();
            for (Map.Entry<String, Job> entry : jobSet.entrySet()) {
                final Job job = entry.getValue();
                boolean hit = true;
                for (String key : job.condSatisfied.keySet()) {
                    if (key.equals(cond.ident)) {
                        job.condSatisfied.put(key, cond.satisfy);
                    }
                    if (hit) {
                        hit = job.condSatisfied.get(key);
                    }
                }
                if (hit) {
                    trigger(job);
                }
            }
            tryReleaseLock();
        }

        private void checkStatusChangedImpl(final String which) {
            tryAcquireLock();
            //while code runs here, all status have been refreshed, we just check all jobs here and
            //try to find out which can be triggered.
            for (String key : jobSet.keySet()) {
                Job job = jobSet.get(key);
                switch (which) {
                    case Job.CHARGING_KEY:
                        if (job.condSatisfied.containsKey(Job.CHARGING_KEY)) {
                            job.condSatisfied.put(Job.CHARGING_KEY,
                                    DeviceStatus.chargingConstraintSatisfied.get());
                        }
                        break;
                    case Job.IDLE_DEVICE_KEY:
                        if (job.condSatisfied.containsKey(Job.IDLE_DEVICE_KEY)) {
                            job.condSatisfied.put(Job.IDLE_DEVICE_KEY,
                                    DeviceStatus.idleConstraintSatisfied.get());
                        }
                        break;
                    case Job.NETWORK_TYPE_KEY:
                        if (job.condSatisfied.containsKey(Job.NETWORK_TYPE_KEY)) {
                            job.condSatisfied.put(Job.NETWORK_TYPE_KEY,
                                    DeviceStatus.networkTypeSatisfied(job.jobInfo.networkType));
                        }
                        break;
                    default:
                        break;
                }
                if (job.condSatisfied.containsKey(which)) {
                    mayTriggerAfterCheck(job);
                }
            }
            tryReleaseLock();
        }

        private void checkDeadlineImpl() {
            tryAcquireLock();
            long now = System.currentTimeMillis();
            for (Map.Entry<String, Job> entry : jobSet.entrySet()) {
                final Job job = entry.getValue();
                long happen = jobHappens.get(job.jobInfo.identity) == null ? -1 : jobHappens.get(job.jobInfo.identity);
                if (happen == -1L && (job.jobInfo.deadline > 0 && job.jobInfo.deadline <= now)) { //not happen yet
                    trigger(job);
                }
            }
            tryReleaseLock();
        }

        private void checkDeviceOnImpl() {
            tryAcquireLock();
            final File dir = new File(getFilesDir(), JOB_PERSIST_DIR);
            if (dir.listFiles() == null)
                return;
            for (File f : dir.listFiles()) {
                if (!f.getName().startsWith("1553")) {
                    f.delete();
                    continue;
                }
                Job.JobInfo info = null;
                try {
                    info = Job.JobInfo.readFromFile(f);
                } catch (IOException ignore) {
                }
                if (info != null) {
                    Job job = Job.createJobFromPersistInfo(info);
                    if (job != null) {
                        jobHappens.remove(job.jobInfo.identity);
                        addJob(job, true);
                    }
                } else {
                    f.delete();
                }
            }
            tryReleaseLock();
        }

        private void removePersistJobWithTagImpl(String tag) {
            File backupDir = new File(getFilesDir(), JOB_BACKUP_DIR);
            File persistDir = new File(getFilesDir(), JOB_PERSIST_DIR);
            if (backupDir.exists() && backupDir.listFiles() != null) {
                for (File f : backupDir.listFiles()) {
                    try {
                        Job.JobInfo info = Job.JobInfo.readFromFile(f);
                        if (info != null && info.tag.equals(tag)) {
                            f.delete();
                        }
                    } catch (IOException ignore) {
                    }
                }
            }
            if (persistDir.exists() && persistDir.listFiles() != null) {
                for (File f : persistDir.listFiles()) {
                    try {
                        Job.JobInfo info = Job.JobInfo.readFromFile(f);
                        if (info != null && info.tag.equals(tag)) {
                            f.delete();
                        }
                    } catch (IOException ignore) {
                    }
                }
            }
        }

        boolean mayTriggerAfterCheck(final Job job) {
            boolean hit = false;
            for (String key : job.condSatisfied.keySet()) {
                if (job.condSatisfied.get(key)) {
                    hit = true;
                } else {
                    hit = false;
                    break;
                }
            }
            if (hit) {
                trigger(job);
            }
            return hit;
        }

        void trigger(final Job job) {
            Log.d(TAG, "trigger() " + job.jobInfo.identity);
            long happen = jobHappens.get(job.jobInfo.identity) == null ? -1 : jobHappens.get(job.jobInfo.identity);
            if (happen != -1 && job.jobInfo.delay != -1) {
                if (SystemClock.elapsedRealtime() - happen < job.jobInfo.delay) {
                    Log.d(TAG, "trigger: Delay hit, last is " + happen);
                    return;
                }
            }
            Runnable r = new Runnable() {
                @Override
                public void run() {
                    Act act = job.action;
                    if (act instanceof Action) {
                        ((Action) act).act();
                    } else if (act instanceof ContextAction) {
                        ((ContextAction) act).act(TriggerLoop.this);
                    }
                }
            };
            if (job.jobInfo.threadSpace == ThreadSpace.MAIN) {
                mainHandler.post(r);
            } else {
                executor.submit(r);
            }
            jobHappens.put(job.jobInfo.identity, SystemClock.elapsedRealtime());
            removeOne(job.jobInfo.identity);
            job.resetConds();
            if (job.jobInfo.repeat) {
                addJob(job, false);
            }
        }


        public void checkSatisfy(ConditionDesc cond) {
            obtainMessage(MSG_SATISFY, cond).sendToTarget();
        }

        public void checkDeadline() {
            sendEmptyMessage(MSG_DEADLINE);
        }

        public void checkDeviceOn() {
            sendEmptyMessage(MSG_DEVICE_ON);
        }

        public void checkStatusChanged(String which) {
            obtainMessage(MSG_STATUS_CHANGED, which).sendToTarget();
        }

        public void removePersistJobWithTag(String tag) {
            obtainMessage(MSG_REMOVE_TAG_JOB, tag).sendToTarget();
        }

        public void cleanup() {
            removeCallbacksAndMessages(null);
        }
    }

    private class DeadlineCheck extends BroadcastReceiver {

        @Override
        public void onReceive(Context context, Intent intent) {
            checker.checkDeadline();
        }
    }

    private class ReceiverInner extends BroadcastReceiver {
        private final Receiver receiver;

        private ReceiverInner(Receiver receiver) {
            this.receiver = receiver;
        }

        @Override
        public void onReceive(Context context, Intent intent) {
            receiver.onReceive(context, intent);
        }
    }

}