package com.uriio.beacons.model;

import android.annotation.SuppressLint;
import android.app.PendingIntent;
import android.bluetooth.le.AdvertiseCallback;
import android.bluetooth.le.AdvertiseSettings;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.os.Build;
import android.os.SystemClock;
import android.support.annotation.NonNull;
import android.support.v4.content.LocalBroadcastManager;
import android.util.Log;

import com.uriio.beacons.Beacons;
import com.uriio.beacons.BleService;
import com.uriio.beacons.BuildConfig;
import com.uriio.beacons.Receiver;
import com.uriio.beacons.Storage;
import com.uriio.beacons.ble.Advertiser;

import java.util.UUID;

/**
 * Base container for an item.
 */
public abstract class Beacon implements Advertiser.SettingsProvider {
    private static final String TAG = "Beacon";
    private static boolean D = BuildConfig.DEBUG;

    /**
     * Beacon is active and should be enabled if Bluetooth is available.
     */
    public static final int ACTIVE_STATE_ENABLED = 0;
    /**
     * Beacon is active but paused.
     */
    public static final int ACTIVE_STATE_PAUSED  = 1;
    /**
     * Beacon is stopped. This is the default initial state.
     */
    public static final int ACTIVE_STATE_STOPPED = 2;

    // Advertise state constants that reflect current BLE status
    public static final int ADVERTISE_STOPPED       = 0;
    public static final int ADVERTISE_RUNNING       = 1;
    public static final int ADVERTISE_NO_BLUETOOTH  = 2;

    @SuppressLint("InlinedApi")
    private static final int DEFAULT_ADVERTISE_MODE = AdvertiseSettings.ADVERTISE_MODE_BALANCED;

    @SuppressLint("InlinedApi")
    private static final int DEFAULT_ADVERTISE_TX_POWER = AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM;

    /** Associated BLE object, if any. **/
    private Advertiser mAdvertiser = null;

    private String mName = null;

    /**
     * Unique beacon ID, needed for finding existing UNSAVED beacons. Cannot use a simple counter
     * for this because it will be different between restarts and we will find the wrong beacon when
     * an Alarm is triggered and we search for the beacon with that exact ID, while a random UUID will not
     * find any beacon at all (but since the beacon was unsaved that's to be expected).
     */
    private final UUID mUUID;

    /** Persistent ID for database purpose **/
    private long mStorageId = 0;

    private static int _lastStableId = 0;
    private final int mStableId = ++_lastStableId;

    private int mFlags;

    private int mAdvertiseMode;
    private int mTxPowerLevel;

    /** Current advertise status. This is the state of the BLE advertising, not of the beacon. **/
    private int mAdvertiseState = ADVERTISE_STOPPED;

    private int mActiveState = ACTIVE_STATE_STOPPED;

    private boolean mConnectable = false;
    private int mErrorCode;
    private String mErrorDetsils;

    /**
     * Creates a Beacon instance using the specified Cursor. Useful for
     * deserializing from a persistent layer such as a database.
     * @param cursor The Cursor to use, positioned at the index of the data to read.
     * @return A Beacon instance, or null in case it could not be created.
     */
    @SuppressWarnings("unused")
    public static Beacon fromCursor(@NonNull Cursor cursor) {
        return Storage.fromCursor(cursor);
    }

    /**
     * Constructs a beacon container.
     * @param advertiseMode        BLE advertising mode
     * @param txPowerLevel         BLE Transmit power level
     * @param name                 An optional name. Not used in actual BLE packets.
     */
    public Beacon(@Advertiser.Mode int advertiseMode,
                  @Advertiser.Power int txPowerLevel, int flags, String name) {
        mUUID = UUID.randomUUID();
        init(0, advertiseMode, txPowerLevel, flags, name);
    }

    public Beacon(@Advertiser.Mode int advertiseMode, @Advertiser.Power int txPowerLevel, int flags) {
        this(advertiseMode, txPowerLevel, flags, null);
    }

    public Beacon(int flags, String name) {
        this(DEFAULT_ADVERTISE_MODE, DEFAULT_ADVERTISE_TX_POWER, flags, name);
    }

    public Beacon(int flags) {
        this(flags, null);
    }

    public Beacon() {
        this(0);
    }

    @NonNull
    @Override
    public String toString() {
        if(BuildConfig.DEBUG) {
            return "stableID " + mStableId + " storeId " + mStorageId + " uuid " + mUUID + " name " + mName;
        }

        return super.toString();
    }

    /**
     * Sets some basic properties. Should only be called immediately after creation, and before save().
     */
    public void init(long storageId,
                     @Advertiser.Mode int advertiseMode,
                     @Advertiser.Power int txPowerLevel, int flags, String name) {
        mStorageId = storageId;
        mFlags = flags;
        mAdvertiseMode = advertiseMode;
        mTxPowerLevel = txPowerLevel;
        mName = name;
    }

    /**
     * Saves this beacon to persistent storage and optionally starts advertising.
     * @param startAdvertising    Enables the beacon to advertise, if not started already.
     * @return Same instance.
     */
    public Beacon save(boolean startAdvertising) {
        // don't save an already persisted beacon
        if (getSavedId() > 0) return this;

        Storage.getInstance().insert(this);

        if (startAdvertising) {
            start();
        }

        return this;
    }

    /**
     * Saves the beacon and enables BLE advertising.
     * @return Same instance.
     */
    @SuppressWarnings("unused")
    public Beacon save() {
        return save(true);
    }

    private void onEditDone(boolean needRestart) {
        if (getSavedId() > 0) {
            Storage.getInstance().update(this);
        }

        if (needRestart) {
            restartBeacon();
        }
    }

    private void restartBeacon() {
        if (ADVERTISE_RUNNING == getAdvertiseState()) {
            setActiveState(ACTIVE_STATE_PAUSED);
            setState(ACTIVE_STATE_ENABLED, false);  // already persisted as enabled
        }
    }

    /**
     * Enables BLE advertising for this beacon.
     * @return True on success. Note that the actual advertising may fail later, this call only transitions the beacon into enabled state.
     */
    public boolean start() {
        if(D) Log.d(TAG, "start() called");

        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            // can't advertise below L
            return false;
        }

        if (Beacons.getActive().size() == 0) {
            Context context = Beacons.getContext();
            if (null == context) return false;

            if(D) Log.d(TAG, "no active beacons, starting service");

            context.startService(new Intent(context, BleService.class));
        }
        return setState(Beacon.ACTIVE_STATE_ENABLED, true);
    }

    /** Stops the beacon and deletes it from storage. */
    public void delete() {
        if (getActiveState() != ACTIVE_STATE_STOPPED) {
            setState(ACTIVE_STATE_STOPPED, false);
        }

        if (getSavedId() > 0) {
            Storage.getInstance().delete(this);
        }
    }

    public void pause() {
        setState(ACTIVE_STATE_PAUSED, true);
    }

    /** Stops the beacon from advertising. */
    public void stop() {
        // change state and save the new state if needed
        setState(ACTIVE_STATE_STOPPED, true);
    }

    private boolean setState(int state, boolean persist) {
        if(D) Log.d(TAG, "setState() called with: state = [" + state + "], persist = [" + persist + "]");

        if (state < 0 || state > 2) {
            return false;
        }

        Beacon targetBeacon = getSavedId() > 0 ? Beacons.findActive(getSavedId()) : Beacons.findActive(getUUID());

        if (null == targetBeacon) {
            // this beacon is not known to be active
            if (state != Beacon.ACTIVE_STATE_STOPPED) {
                // the new state is not 'stopped', so that means it will switch to active

                // if the Beacons singleton is initialized we need to mark ourself as active.
                // If it's not, beacon might get added a second time on Beacons init, if the service
                // is not started at this point (example: stopping last beacon -> starting a new one)
                // If we are not being persisted before Beacons.init, beacon gets discarded.
                // usecase - an app starts its first beacon, service gets created,
                // only the persisted beacons are known and activated.
                // TL;DR - make sure a new unpersisted beacon is kept track of
                if (Beacons.isInitialized()
                        || (0 == mStorageId))
                {
                    Beacons.getActive().add(this);
                    Beacons.onActiveBeaconAdded(this);
                }
            }

            targetBeacon = this;
        }

        if (state != targetBeacon.getActiveState()) {
            if(D) Log.d(TAG, "new state! " + state + " old " + targetBeacon.getActiveState());

            // item changed state
            targetBeacon.setActiveState(state);
            if (persist && targetBeacon.getSavedId() > 0) {
                Storage.getInstance().updateState(targetBeacon, state);
            }

            sendStateBroadcast(targetBeacon);
        }

        return true;
    }

    private static void sendStateBroadcast(Beacon beacon) {
        Context context = Beacons.getContext();
        if (null != context) {
            Intent intent = new Intent(BleService.ACTION_ITEM_STATE);

            if (beacon.getSavedId() > 0) {
                intent.putExtra(BleService.EXTRA_ITEM_STORAGE_ID, beacon.getSavedId());
            }
            else {
                intent.putExtra(BleService.EXTRA_ITEM_ID, beacon.getUUID());
            }

            LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
        }
    }

    public void setActiveState(int state) {
        mActiveState = state;
    }

    public int getActiveState() {
        return mActiveState;
    }

    /**
     * @return Persistent ID of this beacon. If 0, then the beacon is not yet saved.
     */
    public long getSavedId() {
        return mStorageId;
    }

    /**
     * @return A non-persistent unique ID different than any other beacon.
     * The ID is not stable between service restarts and can easily collide.
     */
    @SuppressWarnings("unused")
    public long getStableId() {
        return mStableId;
    }

    /**
     * @return A non-persistent UUID different than any other beacon.
     * The UUID is not stable between service restarts, but it will not collide.
     */
    public UUID getUUID() {
        return mUUID;
    }

    public Advertiser getAdvertiser() {
        return mAdvertiser;
    }

    // region Advertiser.SettingsProvider

    @Override
    @Advertiser.Mode
    public int getAdvertiseMode() {
        return mAdvertiseMode;
    }

    @Override
    @Advertiser.Power
    public int getTxPowerLevel() {
        return mTxPowerLevel;
    }

    @Override
    public int getTimeout() {
        return 0;
    }

    @Override
    public boolean isConnectable() {
        return mConnectable;
    }

    // endregion

    public int getFlags() {
        return mFlags;
    }

    public int getAdvertiseState() {
        return mAdvertiseState;
    }

    public void setAdvertiseState(int status) {
        mAdvertiseState = status;
    }

    public Advertiser recreateAdvertiser(BleService bleService) {
        mErrorCode = 0;
        mErrorDetsils = null;
        return (mAdvertiser = createAdvertiser(bleService));
    }

    protected abstract Advertiser createAdvertiser(BleService advertisersManager);

    public abstract int getKind();

    /**
     * Descriptive name. Not used for BLE advertising purposes.
     */
    public String getName() {
        return mName;
    }

    /** Called by the service when Bluetooth enters disabled state. Never call this directly. */
    public long onBluetoothDisabled(BleService bleService) {
        cancelRefresh(bleService);

        long pduCount = 0;
        setAdvertiseState(ADVERTISE_NO_BLUETOOTH);

        if (null != mAdvertiser) {
            pduCount = mAdvertiser.clearPDUCount();

            // do not attempt to re-use the same callback for future broadcasts
            mAdvertiser = null;
        }

        return pduCount;
    }

    public void onBluetoothEnabled(BleService service) {
        if (ACTIVE_STATE_ENABLED == mActiveState) {
            onAdvertiseEnabled(service);
        } else {
            // beacon was active but not enabled, aka PAUSED
            setAdvertiseState(ADVERTISE_STOPPED);
        }
    }

    public void cancelRefresh(BleService bleService) {
        if(getScheduledRefreshElapsedTime() > 0) {
            // cancel the scheduled beacon recreation
            bleService.cancelAlarm(getAlarmPendingIntent(bleService));
        }
    }

    public void onAdvertiseFailed(int errorCode) {
        if (AdvertiseCallback.ADVERTISE_FAILED_TOO_MANY_ADVERTISERS == errorCode){
            // don't stop - we could attempt to start the beacon again if we free a slot
            pause();
        }
        else {
            // fatal, no point in keeping the beacon in active state
            stop();
        }

        mAdvertiser = null;
        mErrorCode = errorCode;
        setErrorDetails(Advertiser.getErrorName(errorCode));
    }

    public int getErrorCode() {
        return mErrorCode;
    }

    public String getErrorDetsils() {
        return mErrorDetsils;
    }

    /**
     * @return [Display purposes] UNIX timestamp at which the beacon should refresh. This value may
     * be wrong if the beacon must be synced with an external service because the system time may be
     * wrong. Subclasses that need to present an absolute correct time should override this method.
     */
    public long getScheduledRefreshTime() {
        long refreshElapsedTime = getScheduledRefreshElapsedTime();
        return 0 == refreshElapsedTime ? 0 : System.currentTimeMillis() - SystemClock.elapsedRealtime() + refreshElapsedTime;
    }

    /**
     * @return The SystemClock.elapsedRealtime() value on which this beacon should be restarted.
     * This should NOT be computed based on the current system time.
     */
    public long getScheduledRefreshElapsedTime() {
        return 0;
    }

    /**
     * Called when the item should start advertising a new BLE beacon.
     * Default implementation starts a new beacon advertiser; subclasses may override with other behaviour.
     * @param service    BLE Service
     */
    public void onAdvertiseEnabled(BleService service) {
        // (re)create the beacon
        if (!service.startBeaconAdvertiser(this)) {
            if(D) Log.e(TAG, "startBeaconAdvertiser failed");
        }
    }

    public BaseEditor edit() {
        return new BaseEditor();
    }

    /**
     * Sets the persistent item ID. Has no effect if the item already has an ID.
     * @param id    The item ID
     */
    public void setStorageId(long id) {
        if (0 == mStorageId) {
            mStorageId = id;
        }
    }

    public PendingIntent getAlarmPendingIntent(Context context) {
        Intent intent = new Intent(BleService.ACTION_ALARM, null, context, Receiver.class);

        if (getSavedId() > 0) intent.putExtra(BleService.EXTRA_ITEM_STORAGE_ID, getSavedId());
        else intent.putExtra(BleService.EXTRA_ITEM_ID, getUUID());

        // use a unique private request code, or else the returned PendingIntent is "identical" for all beacons, being reused
        return PendingIntent.getBroadcast(context, mStableId, intent, 0);
    }

    public void setErrorDetails(String error) {
        mErrorDetsils = error;
    }

    public CharSequence getNotificationSubject() {
        return null == mName ? "<unnamed>" : mName.substring(0, Math.min(30, mName.length()));
    }

    public class BaseEditor<T> {
        private boolean mNeedsRestart = false;

        public BaseEditor<T> setAdvertiseMode(@Advertiser.Mode int mode) {
            if (mode != mAdvertiseMode) {
                mAdvertiseMode = mode;
                setNeedsRestart();
            }
            return this;
        }

        public BaseEditor<T> setAdvertiseTxPower(@Advertiser.Power int txPowerLevel) {
            if (txPowerLevel != mTxPowerLevel) {
                mTxPowerLevel = txPowerLevel;
                setNeedsRestart();
            }
            return this;
        }

        public BaseEditor<T> setConnectable(boolean connectable) {
            if (connectable != mConnectable) {
                mConnectable = connectable;
                setNeedsRestart();
            }
            return this;
        }

        public BaseEditor<T> setName(String name) {
            if (null == name || !name.equals(mName)) {
                mName = name;
            }
            return this;
        }

        public BaseEditor<T> setFlags(int flags) {
            if (mFlags != flags) {
                mFlags = flags;
                setNeedsRestart();  // ?...
            }
            return this;
        }

        public void apply() {
            onEditDone(mNeedsRestart);
        }

        /**
         * Indicates that the beacon should be restarted
         * after the editor's changes are applied.
         */
        @SuppressWarnings("WeakerAccess")
        protected void setNeedsRestart() {
            mNeedsRestart = true;
        }
    }
}