/*
 * Copyright 2015 Daniel Dittmar
 *
 * 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 dan.dit.whatsthat.riddle.control;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.os.Handler;
import android.os.HandlerThread;
import android.support.annotation.NonNull;
import android.util.Log;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.ViewGroup;
import android.view.animation.AccelerateInterpolator;

import com.github.johnpersano.supertoasts.SuperToast;
import com.plattysoft.leonids.ParticleField;
import com.plattysoft.leonids.ParticleSystem;

import java.sql.Types;

import dan.dit.whatsthat.R;
import dan.dit.whatsthat.achievement.AchievementManager;
import dan.dit.whatsthat.achievement.AchievementProperties;
import dan.dit.whatsthat.riddle.Riddle;
import dan.dit.whatsthat.riddle.RiddleInitializer;
import dan.dit.whatsthat.riddle.RiddleManager;
import dan.dit.whatsthat.riddle.RiddleView;
import dan.dit.whatsthat.riddle.achievement.AchievementDataRiddleType;
import dan.dit.whatsthat.riddle.types.PracticalRiddleType;
import dan.dit.whatsthat.riddle.types.TypesHolder;
import dan.dit.whatsthat.testsubject.TestSubject;
import dan.dit.whatsthat.testsubject.TestSubjectToast;
import dan.dit.whatsthat.util.general.MathFunction;

/**
 * A riddle controller is the class between the RiddleView and the RiddleGame. If closed the controller can
 * no longer be used, it is directly bound to the lifecycle of a RiddleGame.
 * The controller manages the communication between the different threads involved for keeping
 * the riddle running. The game thread is always running and a single thread dedicated to process
 * events like motion events, orientation events and periodic events in a single thread. Also the
 * periodic game drawing is done on this thread. In the background there can exist a separate
 * periodic thread that produces periodic events for the riddle (if requested on startup) or for
 * riddle animations. Keep in mind that starting a ParticleSystem needs to be done over the
 * RiddleGame.<br>The ui thread is invoked for some drawing, for startup and closing a riddle.
 * main UI thread is
 * Created by daniel on 05.04.15.
 */
public class RiddleController implements RiddleAnimationController.OnAnimationCountChangedListener {
    private volatile RiddleGame mRiddleGame;
    private Riddle mRiddle;
    private ViewGroup mRiddleViewContainer;
    private volatile RiddleView mRiddleView;
    private GamePeriodicThread mPeriodicThread;
    private RiddleAnimationController mRiddleAnimationController;
    private Handler mMainHandler;
    private GameHandlerThread mGameThread;
    private final Runnable mDrawAction;
    private final Runnable mPeriodicAction;
    private volatile int mPeriodActionPostedCount;
    private volatile boolean mIsClosing;

    /**
     * Initializes the RiddleController with the RiddleGame that decorates the given Riddle.
     * @param riddleGame The game that decorates the Riddle parameter.
     * @param riddle The riddle decorated by the game.
     */
    RiddleController(@NonNull RiddleGame riddleGame, @NonNull Riddle riddle) {
        mRiddleGame = riddleGame;
        mRiddle = riddle;
        mRiddleAnimationController = new RiddleAnimationController(this);
        mDrawAction = new Runnable() {
            @Override
            public void run() {
                if (mRiddleView != null) {
                    mRiddleView.draw();
                }
            }
        };
        mPeriodicAction = new Runnable() {

            private long mMissingUpdateTime; // will only be zero at start for first game controlled
            @Override
            public void run() {
                long requiredDrawingTime = mRiddleView.performDrawRiddle();
                long updateTime = mMissingUpdateTime + requiredDrawingTime;
                long periodicEventStartTime = System.nanoTime();
                if (updateTime > 0) {
                    mRiddleGame.onPeriodicEvent(updateTime);
                    mRiddleAnimationController.update(updateTime);
                }
                mMissingUpdateTime = (System.nanoTime() - periodicEventStartTime) / 1000000;
                --mPeriodActionPostedCount;
            }
        };
    }

    public Riddle getRiddle() {
        return mRiddle;
    }

    public void forbidRiddleBonusScore() {
        mRiddleGame.setForbidBonus();
    }

    private class GameHandlerThread extends HandlerThread {
        private static final long MIN_TIME_BETWEEN_MOTION_MOVE_EVENTS = 30L;
        private Handler mHandler;
        private long mLastMotionMoveTimestamp;

        public GameHandlerThread() {
            super("GameHandlerThread");
            start();
            Log.d("Riddle", "GameThread started.");
            mHandler = new Handler(getLooper());
        }

        public void onMotionEvent(MotionEvent event) {
            if (event.getActionMasked() == MotionEvent.ACTION_MOVE) {
                long now = System.currentTimeMillis();
                if (now - mLastMotionMoveTimestamp < MIN_TIME_BETWEEN_MOTION_MOVE_EVENTS) {
                    return;
                }
                mLastMotionMoveTimestamp = now;
            }
            final MotionEvent eventCopy = MotionEvent.obtain(event);
            mHandler.post(new Runnable() {
                @Override
                public void run() {
                    if (mRiddleGame != null && mRiddleGame.onMotionEvent(eventCopy)) {
                        mDrawAction.run();
                    }
                    eventCopy.recycle();
                }
            });
        }

        public void onOrientationEvent(final float azimuth, final float pitch, final float roll) {
            mHandler.post(new Runnable() {
                @Override
                public void run() {
                    if (mRiddleGame != null && mRiddleGame.onOrientationEvent(azimuth, pitch,
                            roll)) {
                        mDrawAction.run();
                    }
                }
            });
        }

        public void onPeriodicEvent() {
            if (mPeriodActionPostedCount == 0 && !mIsClosing) {
                ++mPeriodActionPostedCount;
                mHandler.post(mPeriodicAction);
            }
        }

        public Handler getHandler() {
            return mHandler;
        }

        public void onCloseRiddle(final Context context) {
            mIsClosing = true;
            mHandler.post(new Runnable() {
                @Override
                public void run() {
                    //at this point we can be sure that the looper doesn't currently process a
                    // periodic event which could lead to concurrency issues
                    mGameThread.quit(); // do not process any more actions!
                    Log.d("Riddle", "Game thread quit.");
                    // stop periodic event in a safe way, as soon as it is stopped really close
                    // riddle in the main ui thread
                    stopPeriodicEvent(mMainHandler, new Runnable() {
                        @Override
                        public void run() {
                            if (riddleAvailable()) {
                                Log.d("Riddle", "Executing close riddle!");
                                onPreRiddleClose();
                                mRiddleAnimationController.clear();
                                mRiddleGame.close();
                                mRiddleGame = null;
                                onRiddleClosed(context);
                            }
                            mRiddleView = null;
                            mRiddleViewContainer = null;
                        }
                    });
                }
            });
        }
    }
    /**
     * The controller is closing, close the riddle and make it save its state. After this method
     * returns the controller is invalid.
     * @param context A context object required for saving state to permanent storage.
     */
    public final void onCloseRiddle(@NonNull final Context context) {
        Log.d("Riddle", "On close riddle.");
        if (mGameThread != null) {
            mGameThread.onCloseRiddle(context);
        }
    }

    /**
     * Invoked on closure of the controller before the RiddleGame's onClose method is invoked.
     */
    void onPreRiddleClose() {
        RiddleManager.addToCache(mRiddle, mRiddleGame.makeSnapshot());
    }

    // this is overwritten if we don't want the manager to know of this riddle and dont want it saved
    /**
     * Invoked on closure of the controller after the RiddleGame's onClose method returned. The RiddleGame is not
     * a valid member anymore. By default saving achievement data and decorated riddle object.
     * @param context The context required to save to permanent storage.
     */
    void onRiddleClosed(final Context context) {
        Log.d("Riddle", "On riddle closed.");
        if (mRiddle.isSolved() && (mRiddle.isRemade() || !mRiddle.isCustom())) {
            mRiddle.getType().getAchievementData(AchievementManager.getInstance()).onSolvedGame();
        }
        mRiddle.saveToDatabase(context);
        if (mRiddle.isSolved()) {
            RiddleInitializer.INSTANCE.getRiddleManager().onRiddleSolved(mRiddle);
        } else {
            RiddleInitializer.INSTANCE.getRiddleManager().onUnsolvedRiddle(mRiddle);
        }
    }

    /* ************* LAYOUT RELATED METHODS ********************************************************/

    /**
     * Draws the RiddleGame on the given canvas if there is a valid riddle.
     * @param canvas The canvas to draw onto.
     */
    public void draw(Canvas canvas) {
        if (riddleAvailable()) {
            mRiddleAnimationController.draw(canvas, null, RiddleAnimationController
                    .LEVEL_GROUNDING);
            mRiddleAnimationController.draw(canvas, null, RiddleAnimationController
                    .LEVEL_BACKGROUND);
            mRiddleGame.draw(canvas);
            mRiddleAnimationController.draw(canvas, null, RiddleAnimationController
                    .LEVEL_ON_TOP);
        }
    }

    protected void addAnimation(@NonNull RiddleAnimation animation) {
        mRiddleAnimationController.addAnimation(animation);
    }

    protected void addAnimation(@NonNull RiddleAnimation animation, long delay) {
        mRiddleAnimationController.addAnimation(animation, delay);
    }

    /* ************ INPUT RELATED METHODS *********************************************************/

    /**
     * Invoked if there happened some MotionEvent of any kind to the RiddleView.
     * If possible forwards the event to the RiddleGame, redrawing the game if onMotionEvent suggests so.
     * @param event The event to forward.
     */
    public void onMotionEvent(MotionEvent event) {
        if (riddleAvailable()) {
            mGameThread.onMotionEvent(event);
        }
    }

    /**
     * Invoked if there happened some OrientationEvent that changed the orientation of the device in the world's
     * coordinate system and orientation sensor is required.
     * If possible forwards the orientation event to the RiddleGame, redrawing the game if onOrientationEvent suggests so.
     * Given angles in radians, for specification see Wikipedia.
     * @param azimuth The new azimuth.
     * @param pitch The new pitch.
     * @param roll The new roll.
     */
    public void onOrientationEvent(float azimuth, float pitch, float roll) {
        if (riddleAvailable() && requiresOrientationSensor()) {
            mGameThread.onOrientationEvent(azimuth, pitch, roll);
        }
    }

    private boolean riddleAvailable() {
        return mRiddleGame != null && mRiddleGame.isNotClosed();
    }

    /**
     * Invoked when the RiddleView got visible and valid. On the UI thread.
     * @param riddleViewContainer The container that contains the valid RiddleView
     */
    public final void onRiddleVisible(@NonNull ViewGroup riddleViewContainer) {
        mRiddleViewContainer = riddleViewContainer;
        mIsClosing = false;
        mRiddleView = (RiddleView) mRiddleViewContainer.findViewById(R.id.riddle_view);
        mMainHandler = new Handler();
        mGameThread = new GameHandlerThread();
        mGameThread.setUncaughtExceptionHandler(Thread.currentThread().getUncaughtExceptionHandler());
        mRiddleGame.onGotVisible();

        //startRiddleGotVisibleAnimation(); // nice but requires extra periodic thread to be
        // started and overall extra work for little gain
        onRiddleGotVisible();
    }

    protected void startRiddleGotVisibleAnimation() {
        long animationTime = 450;
        float yDelta = -100f;
        mRiddleAnimationController.addAnimation(new RiddleCanvasAnimation.Builder()
                .setInterpolator(new MathFunction.AnimationInterpolator(new
                        AccelerateInterpolator(2.f)))
                .addTranslate(0, yDelta, 0, -yDelta, animationTime)
                .addScale(1f, 1f, 0.5f, 0.0f, animationTime)
                .build());
    }

    // this is overwritten if we don't want the manager to know of this riddle

    /**
     * The riddle just got visible, by default tell the manager this happened.
     */
    void onRiddleGotVisible() {
        RiddleInitializer.INSTANCE.getRiddleManager().onUnsolvedRiddle(mRiddle); // especially important that, if saving this riddle when finished excludes the image from the list since saving is async
    }

    /**
     * Get the id of the currently used riddle. Not necessarily a valid id for newly created riddles!
     * @return The riddle id. Can be an invalid id!
     */
    public long getRiddleId() {
        return mRiddle.getId();
    }

    /**
     * If any valid game, check if its type requires the orientation sensor.
     * @return If the orientation sensor is required.
     */
    public boolean requiresOrientationSensor() {
        return riddleAvailable() && mRiddleGame.requiresOrientationSensor();
    }

    /**
     * Pause the periodic event, stopping future invocations and periodic renderings.
     */
    private synchronized void stopPeriodicEvent(Handler handler, final Runnable toExecute) {
        if (mPeriodicThread != null && mPeriodicThread.isRunning()) {
            Log.d("Riddle", "Stopping periodic event that is running.");
            mPeriodicThread.stopPeriodicEvent(handler, new Runnable() {
                @Override
                public void run() {
                    if (toExecute != null) {
                        toExecute.run();
                    }
                    onPeriodicThreadStopped();
                }
            });
        } else if (toExecute != null) {
            Log.d("Riddle", "Stopping periodic event that was not running.");
            if (handler != null) {
                handler.post(toExecute);
            } else {
                toExecute.run();
            }
        }
    }

    public synchronized void stopPeriodicEvent() {
        stopPeriodicEvent(null, null);
    }

    private void resumePeriodicEventExecute() {
        mPeriodicThread = new GamePeriodicThread(RiddleController.this);
        mPeriodicThread.setUncaughtExceptionHandler(Thread.getDefaultUncaughtExceptionHandler());
        mPeriodicThread.startPeriodicEvent();
    }

    // invoked on ui thread. periodic event stopped completely, any other code to execute after
    // stopping was executed, so check if there is still a riddle available before doing stuff to
    // riddle or periodic thread
    private synchronized void onPeriodicThreadStopped() {
        onAnimationCountChanged(); // check again if we need to resume the periodic thread, can
        // be relevant when the thread is about to be stopped when another animation is added
    }

    /**
     * If there is a valid riddle and a positive periodic event period, resume (or restart) the rendering and periodic threads.
     */
    public synchronized void resumePeriodicEventIfRequired() {
        if (mRiddleGame != null && requiresPeriodicEvent()) {
            resumePeriodicEvent();
        }
    }

    private boolean requiresPeriodicEvent() {
        return mRiddleGame.requiresPeriodicEvent()
                || mRiddleView.getActiveParticleSystemsCount() > 0
                || mRiddleAnimationController.getActiveAnimationsCount() > 0;
    }

    private synchronized void resumePeriodicEvent() {
        if (riddleAvailable() && mRiddleView != null) {
            if (mPeriodicThread == null || !mPeriodicThread.isRunning()) {
                // if thread is not running yet or not anymore, (re)start.
                // use runnable that is posted by previous running thread, if any, to the ui
                // thread to ensure that no concurrency issues can appear
                stopPeriodicEvent(mMainHandler, new Runnable() {
                    @Override
                    public void run() {
                        resumePeriodicEventExecute();
                    }
                });
            }
        }
    }

    /**
     * If the type requires orientation sensor but the device does not supply (all) required sensors, the game is told so
     * and can enable an alternative to the orientation sensor if possible.
     */
    public void enableNoOrientationSensorAlternative() {
        if (riddleAvailable()) {
            mRiddleGame.enableNoOrientationSensorAlternative();
        }
    }

    /**
     * Returns the type of controller's Riddle.
     * @return The type of the current riddle.
     */
    public PracticalRiddleType getRiddleType() {
        return mRiddle.getType();
    }

    /**
     * Returns the image's hash of the controller's Riddle ('s image).
     * @return The hash of the current riddle.
     */
    public String getImageHash() {
        return mRiddle.getImageHash();
    }

    /**
     * The periodic event happened, forward to the RiddleGame if possible.
     */
    void onPeriodicEvent() {
        if (riddleAvailable()) {
            mGameThread.onPeriodicEvent();
        }
    }

    public boolean hasRunningPeriodicThread() {
        return mPeriodicThread != null && mPeriodicThread.isRunning();
    }

    public void checkParty(@NonNull Resources res, @NonNull RiddleView.PartyCallback callback) {
        if (!TestSubject.isInitialized()) {
            return;
        }
        TestSubject subject = TestSubject.getInstance();
        TestSubjectToast toast = new TestSubjectToast(Gravity.CENTER, 0, 0, mRiddle.getType().getIconResId(), 0, SuperToast.Duration.MEDIUM);
        toast.mAnimations = SuperToast.Animations.POPUP;
        toast.mBackgroundColor = res.getColor(R.color.main_background);

        String[] candies = res.getStringArray(subject.getRiddleSolvedResIds());
        RiddleScore riddleScore = mRiddleGame.getGainedScore(false);
        int score = riddleScore.getTotalScore();
        int party = riddleScore.getBonus();

        if (riddleScore.hasBonus()) {
            AchievementProperties data = mRiddle.getType().getAchievementData(null);
            if (data != null) {
                data.increment(AchievementDataRiddleType.KEY_BONUS_GAINED_COUNT, 1L, 0L);
            }
        }

        StringBuilder builder = new StringBuilder();
        if (candies != null && candies.length > 0) {
            int candyIndex = score - TypesHolder.SCORE_MINIMAL;
            if (candyIndex >= candies.length) {
                float bestFrac = 1f/3f;
                candyIndex = (int) (candies.length * (1f - bestFrac)
                        + Math.random() * candies.length * bestFrac);
            }
            builder.append(candies[candyIndex < 0 ? 0 : candyIndex >= candies.length ?
                    candies.length - 1 : candyIndex]);
        }
        if (score > 0) {
            builder.append(" +")
                    .append(score);
        }
        // for each multiplier add an exclamation mark
        for (int i = 0; i < riddleScore.getMultiplicator() - 1; i++) {
            builder.append("!");
        }
        toast.mText = builder.toString();
        toast.mTextSize = 40;
        callback.giveCandy(toast);
        if (party > 0) {
            callback.doParty(party);
        }
        if (score > 0) {
            callback.showMoneyEarned(score);
        }
    }

    @Override
    public void onAnimationCountChanged() {
        if (!riddleAvailable() || mRiddleGame.requiresPeriodicEvent()) {
            return;
        }
        int count = mRiddleAnimationController.getActiveAnimationsCount();
        handlePeriodicEventForCount(count);
    }

    public void onParticleSystemCountChanged() {
        if (!riddleAvailable() || mRiddleGame.requiresPeriodicEvent()) {
            return;
        }
        int count = mRiddleView.getActiveParticleSystemsCount();
        handlePeriodicEventForCount(count);
    }

    private void handlePeriodicEventForCount(int count) {
        if (mRiddleView != null && mRiddleView.isPaused()) {
            return;
        }
        // ensure the following actions take place on ui thread
        if (count == 0) {
            mMainHandler.post(new Runnable() {
                @Override
                public void run() {
                    stopPeriodicEvent(mGameThread.getHandler(), mDrawAction);
                }
            });
        } else if (count > 0) {
            mMainHandler.post(new Runnable() {
                @Override
                public void run() {
                    resumePeriodicEvent();
                }
            });
        }
    }

    public ParticleSystem makeParticleSystem(Resources res, int maxParticles, int drawableResId,
                                             long timeToLive) {
        ParticleField field = mRiddleView;
        if (field == null) {
            return null;
        }
        ParticleSystem system = new ParticleSystem(field, res, maxParticles, timeToLive);
        system.initParticles(res.getDrawable(drawableResId));
        system.setIgnorePositionInParent();
        return system;
    }
}
