package com.mopub.nativeads;

import android.app.Activity;
import android.content.Context;
import android.graphics.Rect;
import android.os.Handler;
import android.os.SystemClock;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.view.View;
import android.view.ViewTreeObserver;

import com.mopub.common.VisibleForTesting;
import com.mopub.common.logging.MoPubLog;

import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;

import static android.view.ViewTreeObserver.OnPreDrawListener;

/**
 * Tracks views to determine when they become visible or invisible, where visibility is defined as
 * having been at least X% on the screen.
 */
class VisibilityTracker {
    // Time interval to use for throttling visibility checks.
    private static final int VISIBILITY_THROTTLE_MILLIS = 100;

    // Trim the tracked views after this many accesses. This protects us against tracking
    // too many views if the developer uses the adapter for multiple ListViews. It also
    // limits the memory leak if a developer forgets to call destroy().
    @VisibleForTesting static final int NUM_ACCESSES_BEFORE_TRIMMING = 50;

    // Temporary array of trimmed views so that we don't allocate this on every trim.
    @NonNull private final ArrayList<View> mTrimmedViews;

    // Incrementing access counter. Use a long to support very long-lived apps.
    private long mAccessCounter = 0;

    // Listener that passes all visible and invisible views when a visibility check occurs
    static interface VisibilityTrackerListener {
        void onVisibilityChanged(List<View> visibleViews, List<View> invisibleViews);
    }

    @Nullable @VisibleForTesting OnPreDrawListener mOnPreDrawListener;
    @NonNull @VisibleForTesting final WeakReference<View> mRootView;

    static class TrackingInfo {
        int mMinViewablePercent;
        long mAccessOrder;
    }

    // Views that are being tracked, mapped to the min viewable percentage
    @NonNull private final Map<View, TrackingInfo> mTrackedViews;

    // Object to check actual visibility
    @NonNull private final VisibilityChecker mVisibilityChecker;

    // Callback listener
    @Nullable private VisibilityTrackerListener mVisibilityTrackerListener;

    // Runnable to run on each visibility loop
    @NonNull private final VisibilityRunnable mVisibilityRunnable;

    // Handler for visibility
    @NonNull private final Handler mVisibilityHandler;

    // Whether the visibility runnable is scheduled
    private boolean mIsVisibilityScheduled;

    public VisibilityTracker(@NonNull final Context context) {
        this(context,
                new WeakHashMap<View, TrackingInfo>(10),
                new VisibilityChecker(),
                new Handler());
    }

    @VisibleForTesting
    VisibilityTracker(@NonNull final Context context,
            @NonNull final Map<View, TrackingInfo> trackedViews,
            @NonNull final VisibilityChecker visibilityChecker,
            @NonNull final Handler visibilityHandler) {
        mTrackedViews = trackedViews;
        mVisibilityChecker = visibilityChecker;
        mVisibilityHandler = visibilityHandler;
        mVisibilityRunnable = new VisibilityRunnable();
        mTrimmedViews = new ArrayList<View>(NUM_ACCESSES_BEFORE_TRIMMING);

        final View rootView = ((Activity) context).getWindow().getDecorView();
        mRootView = new WeakReference<View>(rootView);
        final ViewTreeObserver viewTreeObserver = rootView.getViewTreeObserver();
        if (!viewTreeObserver.isAlive()) {
            MoPubLog.w("Visibility Tracker was unable to track views because the"
                    + " root view tree observer was not alive");
        } else {
            mOnPreDrawListener = new OnPreDrawListener() {
                @Override
                public boolean onPreDraw() {
                    scheduleVisibilityCheck();
                    return true;
                }
            };
            viewTreeObserver.addOnPreDrawListener(mOnPreDrawListener);
        }
    }

    void setVisibilityTrackerListener(
            @Nullable final VisibilityTrackerListener visibilityTrackerListener) {
        mVisibilityTrackerListener = visibilityTrackerListener;
    }

    /**
     * Tracks the given view for visibility.
     */
    void addView(@NonNull final View view, final int minPercentageViewed) {
        // Find the view if already tracked
        TrackingInfo trackingInfo = mTrackedViews.get(view);
        if (trackingInfo == null) {
            trackingInfo = new TrackingInfo();
            mTrackedViews.put(view, trackingInfo);
            scheduleVisibilityCheck();
        }
        trackingInfo.mMinViewablePercent = minPercentageViewed;
        trackingInfo.mAccessOrder = mAccessCounter;

        // Trim the number of tracked views to a reasonable number
        mAccessCounter++;
        if (mAccessCounter % NUM_ACCESSES_BEFORE_TRIMMING == 0) {
            trimTrackedViews(mAccessCounter - NUM_ACCESSES_BEFORE_TRIMMING);
        }
    }

    private void trimTrackedViews(long minAccessOrder) {
        // Clear anything that is below minAccessOrder.
        for (final Map.Entry<View, TrackingInfo> entry : mTrackedViews.entrySet()) {
            if (entry.getValue().mAccessOrder <  minAccessOrder) {
                mTrimmedViews.add(entry.getKey());
            }
        }

        for (View view : mTrimmedViews) {
            removeView(view);
        }
        mTrimmedViews.clear();
    }

    /**
     * Stops tracking a view, cleaning any pending tracking
     */
    void removeView(@NonNull final View view) {
        mTrackedViews.remove(view);
    }

    /**
     * Immediately clear all views. Useful for when we re-request ads for an ad placer
     */
    void clear() {
        mTrackedViews.clear();
        mVisibilityHandler.removeMessages(0);
        mIsVisibilityScheduled = false;
    }

    /**
     * Destroy the visibility tracker, preventing it from future use.
     */
    void destroy() {
        clear();
        final View rootView = mRootView.get();
        if (rootView != null && mOnPreDrawListener != null) {
            final ViewTreeObserver viewTreeObserver = rootView.getViewTreeObserver();
            if (viewTreeObserver.isAlive()) {
                viewTreeObserver.removeOnPreDrawListener(mOnPreDrawListener);
            }
            mOnPreDrawListener = null;
        }
        mVisibilityTrackerListener = null;
    }

    void scheduleVisibilityCheck() {
        // Tracking this directly instead of calling hasMessages directly because we measured that
        // this led to slightly better performance.
        if (mIsVisibilityScheduled) {
            return;
        }

        mIsVisibilityScheduled = true;
        mVisibilityHandler.postDelayed(mVisibilityRunnable, VISIBILITY_THROTTLE_MILLIS);
    }

    class VisibilityRunnable implements Runnable {
        // Set of views that are visible or invisible. We create these once to avoid excessive
        // garbage collection observed when calculating these on each pass.
        @NonNull private final ArrayList<View> mVisibleViews;
        @NonNull private final ArrayList<View> mInvisibleViews;

        VisibilityRunnable() {
            mInvisibleViews = new ArrayList<View>();
            mVisibleViews = new ArrayList<View>();
        }

        @Override
        public void run() {
            mIsVisibilityScheduled = false;
            for (final Map.Entry<View, TrackingInfo> entry : mTrackedViews.entrySet()) {
                final View view = entry.getKey();
                final int minPercentageViewed = entry.getValue().mMinViewablePercent;

                if (mVisibilityChecker.isVisible(view, minPercentageViewed)) {
                    mVisibleViews.add(view);
                } else {
                    mInvisibleViews.add(view);
                }
            }

            if (mVisibilityTrackerListener != null) {
                mVisibilityTrackerListener.onVisibilityChanged(mVisibleViews, mInvisibleViews);
            }

            // Clear these immediately so that we don't leak memory
            mVisibleViews.clear();
            mInvisibleViews.clear();
        }
    }

    static class VisibilityChecker {
        // A rect to use for hit testing. Create this once to avoid excess garbage collection
        private final Rect mClipRect = new Rect();

        /**
         * Whether the visible time has elapsed from the start time. Easily mocked for testing.
         */
        boolean hasRequiredTimeElapsed(final long startTimeMillis, final int minTimeViewed) {
            return SystemClock.uptimeMillis() - startTimeMillis >= minTimeViewed;
        }

        /**
         * Whether the view is at least certain % visible
         */
        boolean isVisible(@Nullable final View view, final int minPercentageViewed) {
            // ListView & GridView both call detachFromParent() for views that can be recycled for
            // new data. This is one of the rare instances where a view will have a null parent for
            // an extended period of time and will not be the main window.
            // view.getGlobalVisibleRect() doesn't check that case, so if the view has visibility
            // of View.VISIBLE but has no parent it is likely in the recycle bin of a
            // ListView / GridView and not on screen.
            if (view == null || view.getVisibility() != View.VISIBLE || view.getParent() == null) {
                return false;
            }

            if (!view.getGlobalVisibleRect(mClipRect)) {
                // Not visible
                return false;
            }

            // % visible check - the cast is to avoid int overflow for large views.
            final long visibleViewArea = (long) mClipRect.height() * mClipRect.width();
            final long totalViewArea = (long) view.getHeight() * view.getWidth();

            if (totalViewArea <= 0) {
                return false;
            }

            return 100 * visibleViewArea >= minPercentageViewed * totalViewArea;
        }
    }
}
