package org.skia.skottie;

import android.animation.Animator;
import android.animation.TimeInterpolator;
import android.graphics.SurfaceTexture;
import android.opengl.GLUtils;
import android.util.Log;
import android.view.Choreographer;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.TextureView;

import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.util.ArrayList;

import javax.microedition.khronos.egl.EGL10;
import javax.microedition.khronos.egl.EGLSurface;

public class SkottieAnimation extends Animator implements Choreographer.FrameCallback,
        TextureView.SurfaceTextureListener, SurfaceHolder.Callback {
    class Config {
        int mSurfaceWidth;
        int mSurfaceHeight;
        boolean mValidSurface;
    }

    private final SkottieRunner mRunner = SkottieRunner.getInstance();
    private static final String LOG_TAG = "SkottiePlayer";

    private boolean mIsRunning = false;
    private SurfaceTexture mSurfaceTexture;
    private EGLSurface mEglSurface;
    private boolean mNewSurface = false;
    private SurfaceHolder mSurfaceHolder;
    private int mRepeatCount;
    private int mRepeatCounter;
    private Config config = new Config();
    private int mBackgroundColor;
    private long mNativeProxy;
    private long mDuration;  // duration in ms of the animation
    private float mProgress; // animation progress in the range of 0.0f to 1.0f
    private long mAnimationStartTime; // time in System.nanoTime units, when started

    SkottieAnimation(SurfaceTexture surfaceTexture, InputStream is) {
        if (init(is)) {
            mSurfaceTexture = surfaceTexture;
        }
    }
    SkottieAnimation(TextureView view, InputStream is, int backgroundColor, int repeatCount) {
        if (init(is)) {
            mSurfaceTexture = view.getSurfaceTexture();
        }
        view.setSurfaceTextureListener(this);
        mBackgroundColor = backgroundColor;
        mRepeatCount = repeatCount;
        mRepeatCounter = mRepeatCount;
    }

    SkottieAnimation(SurfaceView view, InputStream is, int backgroundColor, int repeatCount) {
        if (init(is)) {
            mSurfaceHolder = view.getHolder();
        }
        mSurfaceHolder.addCallback(this);
        mBackgroundColor = backgroundColor;
        mRepeatCount = repeatCount;
        mRepeatCounter = mRepeatCount;
    }

    void setSurfaceTexture(SurfaceTexture s) {
        mSurfaceTexture = s;
    }

    private ByteBuffer convertToByteBuffer(InputStream is) throws IOException {
        if (is instanceof FileInputStream) {
            FileChannel fileChannel = ((FileInputStream)is).getChannel();
            return fileChannel.map(FileChannel.MapMode.READ_ONLY,
                                   fileChannel.position(), fileChannel.size());
        }

        ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
        byte[] tmpStorage = new byte[4096];
        int bytesRead;
        while ((bytesRead = is.read(tmpStorage, 0, tmpStorage.length)) != -1) {
            byteStream.write(tmpStorage, 0, bytesRead);
        }

        byteStream.flush();
        tmpStorage = byteStream.toByteArray();

        ByteBuffer buffer = ByteBuffer.allocateDirect(tmpStorage.length);
        buffer.order(ByteOrder.nativeOrder());
        buffer.put(tmpStorage, 0, tmpStorage.length);
        return buffer.asReadOnlyBuffer();
    }

    private boolean init(InputStream is) {

        ByteBuffer byteBuffer;
        try {
            byteBuffer = convertToByteBuffer(is);
        } catch (IOException e) {
            Log.e(LOG_TAG, "failed to read input stream", e);
            return false;
        }

        long proxy = mRunner.getNativeProxy();
        mNativeProxy = nCreateProxy(proxy, byteBuffer);
        mDuration = nGetDuration(mNativeProxy);
        mProgress = 0f;
        return true;
    }

    private void notifyAnimationEnd() {
        if (this.getListeners() != null) {
            // Listeners may remove themselves during onAnimationEnd().  To safeguard agaist
            // mutation, use a list copy.
            ArrayList<Animator.AnimatorListener> listeners =
                    new ArrayList<Animator.AnimatorListener>(this.getListeners());
            for (AnimatorListener l : listeners) {
                l.onAnimationEnd(this);
            }
        }
    }

    @Override
    protected void finalize() throws Throwable {
        try {
            end();
            nDeleteProxy(mNativeProxy);
            mNativeProxy = 0;
        } finally {
            super.finalize();
        }
    }

    // Always call this on GL thread
    public void updateSurface(int width, int height) {
        config.mSurfaceWidth = width;
        config.mSurfaceHeight = height;
        mNewSurface = true;
        drawFrame();
    }

    @Override
    public void start() {
        try {
            mRunner.runOnGLThread(() -> {
                if (!mIsRunning) {
                    long currentTime = System.nanoTime();
                    mAnimationStartTime = currentTime - (long)(1000000 * mDuration * mProgress);
                    mIsRunning = true;
                    mNewSurface = true;
                    mRepeatCounter = mRepeatCount;
                    doFrame(currentTime);
                }
            });
        }
        catch (Throwable t) {
            Log.e(LOG_TAG, "start failed", t);
            throw new RuntimeException(t);
        }
        if (this.getListeners() != null) {
            for (AnimatorListener l : this.getListeners()) {
                l.onAnimationStart(this);
            }
        }
    }

    @Override
    public void end() {
        try {
            mRunner.runOnGLThread(() -> {
                mIsRunning = false;
                if (mEglSurface != null) {
                    // Ensure we always have a valid surface & context.
                    mRunner.mEgl.eglMakeCurrent(mRunner.mEglDisplay, mRunner.mPBufferSurface,
                            mRunner.mPBufferSurface, mRunner.mEglContext);
                    mRunner.mEgl.eglDestroySurface(mRunner.mEglDisplay, mEglSurface);
                    mEglSurface = null;
                }
            });
        }
        catch (Throwable t) {
            Log.e(LOG_TAG, "stop failed", t);
            throw new RuntimeException(t);
        }
        notifyAnimationEnd();
    }

    @Override
    public void pause() {
        try {
            mRunner.runOnGLThread(() -> {
                mIsRunning = false;
            });
        }
        catch (Throwable t) {
            Log.e(LOG_TAG, "pause failed", t);
            throw new RuntimeException(t);
        }
    }

    @Override
    public void resume() {
        try {
            mRunner.runOnGLThread(() -> {
                if (!mIsRunning) {
                    long currentTime = System.nanoTime();
                    mAnimationStartTime = currentTime - (long)(1000000 * mDuration * mProgress);
                    mIsRunning = true;
                    mNewSurface = true;
                    doFrame(currentTime);
                }
            });
        }
        catch (Throwable t) {
            Log.e(LOG_TAG, "resume failed", t);
            throw new RuntimeException(t);
        }
    }

    // TODO: add support for start delay
    @Override
    public long getStartDelay() {
        return 0;
    }

    // TODO: add support for start delay
    @Override
    public void setStartDelay(long startDelay) {

    }

    @Override
    public Animator setDuration(long duration) {
        return null;
    }

    @Override
    public boolean isRunning() {
        return mIsRunning;
    }

    @Override
    public long getDuration() {
        return mDuration;
    }

    @Override
    public long getTotalDuration() {
        if (mRepeatCount == -1) {
            return DURATION_INFINITE;
        }
        // TODO: add start delay when implemented
        return mDuration * (1 + mRepeatCount);
    }

    // TODO: support TimeInterpolators
    @Override
    public void setInterpolator(TimeInterpolator value) {

    }

    public void setProgress(float progress) {
        try {
            mRunner.runOnGLThread(() -> {
                mProgress = progress;
                if (mIsRunning) {
                    mAnimationStartTime = System.nanoTime()
                            - (long)(1000000 * mDuration * mProgress);
                }
                drawFrame();
            });
        }
        catch (Throwable t) {
            Log.e(LOG_TAG, "setProgress failed", t);
            throw new RuntimeException(t);
        }
    }

    public float getProgress() {
        return mProgress;
    }

    private void drawFrame() {
        try {
            boolean forceDraw = false;
            if (mNewSurface) {
                forceDraw = true;
                // if there is a new SurfaceTexture, we need to recreate the EGL surface.
                if (mEglSurface != null) {
                    mRunner.mEgl.eglDestroySurface(mRunner.mEglDisplay, mEglSurface);
                    mEglSurface = null;
                }
                mNewSurface = false;
            }

            if (mEglSurface == null) {
                // block for Texture Views
                if (mSurfaceTexture != null) {
                    mEglSurface = mRunner.mEgl.eglCreateWindowSurface(mRunner.mEglDisplay,
                            mRunner.mEglConfig, mSurfaceTexture, null);
                    checkSurface();
                // block for Surface Views
                } else if (mSurfaceHolder != null) {
                    mEglSurface = mRunner.mEgl.eglCreateWindowSurface(mRunner.mEglDisplay,
                            mRunner.mEglConfig, mSurfaceHolder, null);
                    checkSurface();
                }
            }

            if (mEglSurface != null) {
                if (!mRunner.mEgl.eglMakeCurrent(mRunner.mEglDisplay, mEglSurface, mEglSurface,
                        mRunner.mEglContext)) {
                    // If eglMakeCurrent failed, recreate EGL surface on next frame.
                    Log.w(LOG_TAG, "eglMakeCurrent failed "
                            + GLUtils.getEGLErrorString(mRunner.mEgl.eglGetError()));
                    mNewSurface = true;
                    return;
                }
                // only if nDrawFrames() returns true do we need to swap buffers
                if(nDrawFrame(mNativeProxy, config.mSurfaceWidth, config.mSurfaceHeight, false,
                        mProgress, mBackgroundColor, forceDraw)) {
                    if (!mRunner.mEgl.eglSwapBuffers(mRunner.mEglDisplay, mEglSurface)) {
                        int error = mRunner.mEgl.eglGetError();
                        if (error == EGL10.EGL_BAD_SURFACE
                            || error == EGL10.EGL_BAD_NATIVE_WINDOW) {
                            // For some reason our surface was destroyed. Recreate EGL surface
                            // on next frame.
                            mNewSurface = true;
                            // This really shouldn't happen, but if it does we can recover
                            // easily by just not trying to use the surface anymore
                            Log.w(LOG_TAG, "swapBuffers failed "
                                + GLUtils.getEGLErrorString(error));
                            return;
                        }

                        // Some other fatal EGL error happened, log an error and stop the
                        // animation.
                        throw new RuntimeException("Cannot swap buffers "
                            + GLUtils.getEGLErrorString(error));
                    }
                }


                // If animation stopped, release EGL surface.
                if (!mIsRunning) {
                    // Ensure we always have a valid surface & context.
                    mRunner.mEgl.eglMakeCurrent(mRunner.mEglDisplay, mRunner.mPBufferSurface,
                            mRunner.mPBufferSurface, mRunner.mEglContext);
                    mRunner.mEgl.eglDestroySurface(mRunner.mEglDisplay, mEglSurface);
                    mEglSurface = null;
                }
            }
        } catch (Throwable t) {
            Log.e(LOG_TAG, "drawFrame failed", t);
            mIsRunning = false;
        }
    }

    private void checkSurface() throws RuntimeException {
        // ensure eglSurface was created
        if (mEglSurface == null || mEglSurface == EGL10.EGL_NO_SURFACE) {
            // If failed to create a surface, log an error and stop the animation
            int error = mRunner.mEgl.eglGetError();
            throw new RuntimeException("createWindowSurface failed "
                + GLUtils.getEGLErrorString(error));
        }
    }

    @Override
    public void doFrame(long frameTimeNanos) {
        if (mIsRunning) {
            // Schedule next frame.
            Choreographer.getInstance().postFrameCallback(this);

            // Advance animation.
            long durationNS = mDuration * 1000000;
            long timeSinceAnimationStartNS = frameTimeNanos - mAnimationStartTime;
            long animationProgressNS = timeSinceAnimationStartNS % durationNS;
            mProgress = animationProgressNS / (float)durationNS;
            if (timeSinceAnimationStartNS > durationNS) {
                mAnimationStartTime += durationNS;  // prevents overflow
            }
            if (timeSinceAnimationStartNS > durationNS) {
                if (mRepeatCounter > 0) {
                    mRepeatCounter--;
                } else if (mRepeatCounter == 0) {
                    mIsRunning = false;
                    mProgress = 1;
                    notifyAnimationEnd();
                }
            }
        }
        if (config.mValidSurface) {
            drawFrame();
        }
    }

    @Override
    public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
        // will be called on UI thread
        try {
            mRunner.runOnGLThread(() -> {
                mSurfaceTexture = surface;
                updateSurface(width, height);
                config.mValidSurface = true;
            });
        }
        catch (Throwable t) {
            Log.e(LOG_TAG, "updateSurface failed", t);
            throw new RuntimeException(t);
        }
    }

    @Override
    public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
        // will be called on UI thread
        onSurfaceTextureAvailable(surface, width, height);
    }

    @Override
    public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
        // will be called on UI thread
        onSurfaceTextureAvailable(null, 0, 0);
        config.mValidSurface = false;
        return true;
    }

    @Override
    public void onSurfaceTextureUpdated(SurfaceTexture surfaceTexture) {

    }

    // Inherited from SurfaceHolder
    @Override
    public void surfaceCreated(SurfaceHolder holder) {
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        try {
            mRunner.runOnGLThread(() -> {
                mSurfaceHolder = holder;
                updateSurface(width, height);
                config.mValidSurface = true;
            });
        }
        catch (Throwable t) {
            Log.e(LOG_TAG, "updateSurface failed", t);
            throw new RuntimeException(t);
        }
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        config.mValidSurface = false;
        surfaceChanged(null, 0, 0, 0);
    }

    Config getBackingViewConfig() {
        return config;
    }

    void setBackingViewConfig(Config config) {
        this.config.mSurfaceHeight = config.mSurfaceHeight;
        this.config.mSurfaceWidth = config.mSurfaceWidth;
        this.config.mValidSurface = config.mValidSurface;
    }

    private native long nCreateProxy(long runner, ByteBuffer data);
    private native void nDeleteProxy(long nativeProxy);
    private native boolean nDrawFrame(long nativeProxy, int width, int height,
                                      boolean wideColorGamut, float progress,
                                      int backgroundColor, boolean forceDraw);
    private native long nGetDuration(long nativeProxy);
}
