/**
 * Copyright 2016 Keepsafe Software, Inc.
 * <p>
 * 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
 * <p>
 * http://www.apache.org/licenses/LICENSE-2.0
 * <p>
 * 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.getkeepsafe.taptargetview;

import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Outline;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PixelFormat;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.Region;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.os.Build;
import androidx.annotation.Nullable;
import android.text.DynamicLayout;
import android.text.Layout;
import android.text.SpannableStringBuilder;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewManager;
import android.view.ViewOutlineProvider;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
import android.view.animation.AccelerateDecelerateInterpolator;

/**
 * TapTargetView implements a feature discovery paradigm following Google's Material Design
 * guidelines.
 * <p>
 * This class should not be instantiated directly. Instead, please use the
 * {@link #showFor(Activity, TapTarget, Listener)} static factory method instead.
 * <p>
 * More information can be found here:
 * https://material.google.com/growth-communications/feature-discovery.html#feature-discovery-design
 */
@SuppressLint("ViewConstructor")
public class TapTargetView extends View {
  private boolean isDismissed = false;
  private boolean isDismissing = false;
  private boolean isInteractable = true;

  final int TARGET_PADDING;
  final int TARGET_RADIUS;
  final int TARGET_PULSE_RADIUS;
  final int TEXT_PADDING;
  final int TEXT_SPACING;
  final int TEXT_MAX_WIDTH;
  final int TEXT_POSITIONING_BIAS;
  final int CIRCLE_PADDING;
  final int GUTTER_DIM;
  final int SHADOW_DIM;
  final int SHADOW_JITTER_DIM;

  @Nullable
  final ViewGroup boundingParent;
  final ViewManager parent;
  final TapTarget target;
  final Rect targetBounds;

  final TextPaint titlePaint;
  final TextPaint descriptionPaint;
  final Paint outerCirclePaint;
  final Paint outerCircleShadowPaint;
  final Paint targetCirclePaint;
  final Paint targetCirclePulsePaint;

  CharSequence title;
  @Nullable
  StaticLayout titleLayout;
  @Nullable
  CharSequence description;
  @Nullable
  StaticLayout descriptionLayout;
  boolean isDark;
  boolean debug;
  boolean shouldTintTarget;
  boolean shouldDrawShadow;
  boolean cancelable;
  boolean visible;

  // Debug related variables
  @Nullable
  SpannableStringBuilder debugStringBuilder;
  @Nullable
  DynamicLayout debugLayout;
  @Nullable
  TextPaint debugTextPaint;
  @Nullable
  Paint debugPaint;

  // Drawing properties
  Rect drawingBounds;
  Rect textBounds;

  Path outerCirclePath;
  float outerCircleRadius;
  int calculatedOuterCircleRadius;
  int[] outerCircleCenter;
  int outerCircleAlpha;

  float targetCirclePulseRadius;
  int targetCirclePulseAlpha;

  float targetCircleRadius;
  int targetCircleAlpha;

  int textAlpha;
  int dimColor;

  float lastTouchX;
  float lastTouchY;

  int topBoundary;
  int bottomBoundary;

  Bitmap tintedTarget;

  Listener listener;

  @Nullable
  ViewOutlineProvider outlineProvider;

  public static TapTargetView showFor(Activity activity, TapTarget target) {
    return showFor(activity, target, null);
  }

  public static TapTargetView showFor(Activity activity, TapTarget target, Listener listener) {
    if (activity == null) throw new IllegalArgumentException("Activity is null");

    final ViewGroup decor = (ViewGroup) activity.getWindow().getDecorView();
    final ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(
        ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
    final ViewGroup content = (ViewGroup) decor.findViewById(android.R.id.content);
    final TapTargetView tapTargetView = new TapTargetView(activity, decor, content, target, listener);
    decor.addView(tapTargetView, layoutParams);

    return tapTargetView;
  }

  public static TapTargetView showFor(Dialog dialog, TapTarget target) {
    return showFor(dialog, target, null);
  }

  public static TapTargetView showFor(Dialog dialog, TapTarget target, Listener listener) {
    if (dialog == null) throw new IllegalArgumentException("Dialog is null");

    final Context context = dialog.getContext();
    final WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
    final WindowManager.LayoutParams params = new WindowManager.LayoutParams();
    params.type = WindowManager.LayoutParams.TYPE_APPLICATION;
    params.format = PixelFormat.RGBA_8888;
    params.flags = 0;
    params.gravity = Gravity.START | Gravity.TOP;
    params.x = 0;
    params.y = 0;
    params.width = WindowManager.LayoutParams.MATCH_PARENT;
    params.height = WindowManager.LayoutParams.MATCH_PARENT;

    final TapTargetView tapTargetView = new TapTargetView(context, windowManager, null, target, listener);
    windowManager.addView(tapTargetView, params);

    return tapTargetView;
  }

  public static class Listener {
    /** Signals that the user has clicked inside of the target **/
    public void onTargetClick(TapTargetView view) {
      view.dismiss(true);
    }

    /** Signals that the user has long clicked inside of the target **/
    public void onTargetLongClick(TapTargetView view) {
      onTargetClick(view);
    }

    /** If cancelable, signals that the user has clicked outside of the outer circle **/
    public void onTargetCancel(TapTargetView view) {
      view.dismiss(false);
    }

    /** Signals that the user clicked on the outer circle portion of the tap target **/
    public void onOuterCircleClick(TapTargetView view) {
      // no-op as default
    }

    /**
     * Signals that the tap target has been dismissed
     * @param userInitiated Whether the user caused this action
     */
    public void onTargetDismissed(TapTargetView view, boolean userInitiated) {
    }
  }

  final FloatValueAnimatorBuilder.UpdateListener expandContractUpdateListener = new FloatValueAnimatorBuilder.UpdateListener() {
    @Override
    public void onUpdate(float lerpTime) {
      final float newOuterCircleRadius = calculatedOuterCircleRadius * lerpTime;
      final boolean expanding = newOuterCircleRadius > outerCircleRadius;
      if (!expanding) {
        // When contracting we need to invalidate the old drawing bounds. Otherwise
        // you will see artifacts as the circle gets smaller
        calculateDrawingBounds();
      }

      final float targetAlpha = target.outerCircleAlpha * 255;
      outerCircleRadius = newOuterCircleRadius;
      outerCircleAlpha = (int) Math.min(targetAlpha, (lerpTime * 1.5f * targetAlpha));
      outerCirclePath.reset();
      outerCirclePath.addCircle(outerCircleCenter[0], outerCircleCenter[1], outerCircleRadius, Path.Direction.CW);

      targetCircleAlpha = (int) Math.min(255.0f, (lerpTime * 1.5f * 255.0f));

      if (expanding) {
        targetCircleRadius = TARGET_RADIUS * Math.min(1.0f, lerpTime * 1.5f);
      } else {
        targetCircleRadius = TARGET_RADIUS * lerpTime;
        targetCirclePulseRadius *= lerpTime;
      }

      textAlpha = (int) (delayedLerp(lerpTime, 0.7f) * 255);

      if (expanding) {
        calculateDrawingBounds();
      }

      invalidateViewAndOutline(drawingBounds);
    }
  };

  final ValueAnimator expandAnimation = new FloatValueAnimatorBuilder()
      .duration(250)
      .delayBy(250)
      .interpolator(new AccelerateDecelerateInterpolator())
      .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() {
        @Override
        public void onUpdate(float lerpTime) {
          expandContractUpdateListener.onUpdate(lerpTime);
        }
      })
      .onEnd(new FloatValueAnimatorBuilder.EndListener() {
        @Override
        public void onEnd() {
          pulseAnimation.start();
          isInteractable = true;
        }
      })
      .build();

  final ValueAnimator pulseAnimation = new FloatValueAnimatorBuilder()
      .duration(1000)
      .repeat(ValueAnimator.INFINITE)
      .interpolator(new AccelerateDecelerateInterpolator())
      .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() {
        @Override
        public void onUpdate(float lerpTime) {
          final float pulseLerp = delayedLerp(lerpTime, 0.5f);
          targetCirclePulseRadius = (1.0f + pulseLerp) * TARGET_RADIUS;
          targetCirclePulseAlpha = (int) ((1.0f - pulseLerp) * 255);
          targetCircleRadius = TARGET_RADIUS + halfwayLerp(lerpTime) * TARGET_PULSE_RADIUS;

          if (outerCircleRadius != calculatedOuterCircleRadius) {
            outerCircleRadius = calculatedOuterCircleRadius;
          }

          calculateDrawingBounds();
          invalidateViewAndOutline(drawingBounds);
        }
      })
      .build();

  final ValueAnimator dismissAnimation = new FloatValueAnimatorBuilder(true)
      .duration(250)
      .interpolator(new AccelerateDecelerateInterpolator())
      .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() {
        @Override
        public void onUpdate(float lerpTime) {
          expandContractUpdateListener.onUpdate(lerpTime);
        }
      })
      .onEnd(new FloatValueAnimatorBuilder.EndListener() {
        @Override
        public void onEnd() {
          finishDismiss(true);
        }
      })
      .build();

  private final ValueAnimator dismissConfirmAnimation = new FloatValueAnimatorBuilder()
      .duration(250)
      .interpolator(new AccelerateDecelerateInterpolator())
      .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() {
        @Override
        public void onUpdate(float lerpTime) {
          final float spedUpLerp = Math.min(1.0f, lerpTime * 2.0f);
          outerCircleRadius = calculatedOuterCircleRadius * (1.0f + (spedUpLerp * 0.2f));
          outerCircleAlpha = (int) ((1.0f - spedUpLerp) * target.outerCircleAlpha * 255.0f);
          outerCirclePath.reset();
          outerCirclePath.addCircle(outerCircleCenter[0], outerCircleCenter[1], outerCircleRadius, Path.Direction.CW);
          targetCircleRadius = (1.0f - lerpTime) * TARGET_RADIUS;
          targetCircleAlpha = (int) ((1.0f - lerpTime) * 255.0f);
          targetCirclePulseRadius = (1.0f + lerpTime) * TARGET_RADIUS;
          targetCirclePulseAlpha = (int) ((1.0f - lerpTime) * targetCirclePulseAlpha);
          textAlpha = (int) ((1.0f - spedUpLerp) * 255.0f);
          calculateDrawingBounds();
          invalidateViewAndOutline(drawingBounds);
        }
      })
      .onEnd(new FloatValueAnimatorBuilder.EndListener() {
        @Override
        public void onEnd() {
          finishDismiss(true);
        }
      })
      .build();

  private ValueAnimator[] animators = new ValueAnimator[]
      {expandAnimation, pulseAnimation, dismissConfirmAnimation, dismissAnimation};

  private final ViewTreeObserver.OnGlobalLayoutListener globalLayoutListener;

  /**
   * This constructor should only be used directly for very specific use cases not covered by
   * the static factory methods.
   *
   * @param context The host context
   * @param parent The parent that this TapTargetView will become a child of. This parent should
   *               allow the largest possible area for this view to utilize
   * @param boundingParent Optional. Will be used to calculate boundaries if needed. For example,
   *                       if your view is added to the decor view of your Window, then you want
   *                       to adjust for system ui like the navigation bar or status bar, and so
   *                       you would pass in the content view (which doesn't include system ui)
   *                       here.
   * @param target The {@link TapTarget} to target
   * @param userListener Optional. The {@link Listener} instance for this view
   */
  public TapTargetView(final Context context,
                       final ViewManager parent,
                       @Nullable final ViewGroup boundingParent,
                       final TapTarget target,
                       @Nullable final Listener userListener) {
    super(context);
    if (target == null) throw new IllegalArgumentException("Target cannot be null");

    this.target = target;
    this.parent = parent;
    this.boundingParent = boundingParent;
    this.listener = userListener != null ? userListener : new Listener();
    this.title = target.title;
    this.description = target.description;

    TARGET_PADDING = UiUtil.dp(context, 20);
    CIRCLE_PADDING = UiUtil.dp(context, 40);
    TARGET_RADIUS = UiUtil.dp(context, target.targetRadius);
    TEXT_PADDING = UiUtil.dp(context, 40);
    TEXT_SPACING = UiUtil.dp(context, 8);
    TEXT_MAX_WIDTH = UiUtil.dp(context, 360);
    TEXT_POSITIONING_BIAS = UiUtil.dp(context, 20);
    GUTTER_DIM = UiUtil.dp(context, 88);
    SHADOW_DIM = UiUtil.dp(context, 8);
    SHADOW_JITTER_DIM = UiUtil.dp(context, 1);
    TARGET_PULSE_RADIUS = (int) (0.1f * TARGET_RADIUS);

    outerCirclePath = new Path();
    targetBounds = new Rect();
    drawingBounds = new Rect();

    titlePaint = new TextPaint();
    titlePaint.setTextSize(target.titleTextSizePx(context));
    titlePaint.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL));
    titlePaint.setAntiAlias(true);

    descriptionPaint = new TextPaint();
    descriptionPaint.setTextSize(target.descriptionTextSizePx(context));
    descriptionPaint.setTypeface(Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL));
    descriptionPaint.setAntiAlias(true);
    descriptionPaint.setAlpha((int) (0.54f * 255.0f));

    outerCirclePaint = new Paint();
    outerCirclePaint.setAntiAlias(true);
    outerCirclePaint.setAlpha((int) (target.outerCircleAlpha * 255.0f));

    outerCircleShadowPaint = new Paint();
    outerCircleShadowPaint.setAntiAlias(true);
    outerCircleShadowPaint.setAlpha(50);
    outerCircleShadowPaint.setStyle(Paint.Style.STROKE);
    outerCircleShadowPaint.setStrokeWidth(SHADOW_JITTER_DIM);
    outerCircleShadowPaint.setColor(Color.BLACK);

    targetCirclePaint = new Paint();
    targetCirclePaint.setAntiAlias(true);

    targetCirclePulsePaint = new Paint();
    targetCirclePulsePaint.setAntiAlias(true);

    applyTargetOptions(context);

    final boolean hasKitkat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
    final boolean translucentStatusBar;
    final boolean translucentNavigationBar;
    final boolean layoutNoLimits;

    if (context instanceof Activity) {
      Activity activity = (Activity) context;
      final int flags = activity.getWindow().getAttributes().flags;
      translucentStatusBar = hasKitkat && (flags & WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) != 0;
      translucentNavigationBar = hasKitkat && (flags & WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) != 0;
      layoutNoLimits = (flags & WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) != 0;
    } else {
      translucentStatusBar = false;
      translucentNavigationBar = false;
      layoutNoLimits = false;
    }

    globalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
      @Override
      public void onGlobalLayout() {
        if (isDismissing) {
          return;
        }
        updateTextLayouts();
        target.onReady(new Runnable() {
          @Override
          public void run() {
            final int[] offset = new int[2];

            targetBounds.set(target.bounds());

            getLocationOnScreen(offset);
            targetBounds.offset(-offset[0], -offset[1]);

            if (boundingParent != null) {
              final WindowManager windowManager
                  = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
              final DisplayMetrics displayMetrics = new DisplayMetrics();
              windowManager.getDefaultDisplay().getMetrics(displayMetrics);

              final Rect rect = new Rect();
              boundingParent.getWindowVisibleDisplayFrame(rect);
              int[] parentLocation = new int[2];
              boundingParent.getLocationInWindow(parentLocation);

              if (translucentStatusBar) {
                rect.top = parentLocation[1];
              }
              if (translucentNavigationBar) {
                rect.bottom = parentLocation[1] + boundingParent.getHeight();
              }

              // We bound the boundaries to be within the screen's coordinates to
              // handle the case where the flag FLAG_LAYOUT_NO_LIMITS is set
              if (layoutNoLimits) {
                topBoundary = Math.max(0, rect.top);
                bottomBoundary = Math.min(rect.bottom, displayMetrics.heightPixels);
              } else {
                topBoundary = rect.top;
                bottomBoundary = rect.bottom;
              }
            }

            drawTintedTarget();
            requestFocus();
            calculateDimensions();

            startExpandAnimation();
          }
        });
      }
    };

    getViewTreeObserver().addOnGlobalLayoutListener(globalLayoutListener);

    setFocusableInTouchMode(true);
    setClickable(true);
    setOnClickListener(new OnClickListener() {
      @Override
      public void onClick(View v) {
        if (listener == null || outerCircleCenter == null || !isInteractable) return;

        final boolean clickedInTarget =
            distance(targetBounds.centerX(), targetBounds.centerY(), (int) lastTouchX, (int) lastTouchY) <= targetCircleRadius;
        final double distanceToOuterCircleCenter = distance(outerCircleCenter[0], outerCircleCenter[1],
            (int) lastTouchX, (int) lastTouchY);
        final boolean clickedInsideOfOuterCircle = distanceToOuterCircleCenter <= outerCircleRadius;

        if (clickedInTarget) {
          isInteractable = false;
          listener.onTargetClick(TapTargetView.this);
        } else if (clickedInsideOfOuterCircle) {
          listener.onOuterCircleClick(TapTargetView.this);
        } else if (cancelable) {
          isInteractable = false;
          listener.onTargetCancel(TapTargetView.this);
        }
      }
    });

    setOnLongClickListener(new OnLongClickListener() {
      @Override
      public boolean onLongClick(View v) {
        if (listener == null) return false;

        if (targetBounds.contains((int) lastTouchX, (int) lastTouchY)) {
          listener.onTargetLongClick(TapTargetView.this);
          return true;
        }

        return false;
      }
    });
  }

  private void startExpandAnimation() {
    if (!visible) {
      isInteractable = false;
      expandAnimation.start();
      visible = true;
    }
  }

  protected void applyTargetOptions(Context context) {
    shouldTintTarget = !target.transparentTarget && target.tintTarget;
    shouldDrawShadow = target.drawShadow;
    cancelable = target.cancelable;

    // We can't clip out portions of a view outline, so if the user specified a transparent
    // target, we need to fallback to drawing a jittered shadow approximation
    if (shouldDrawShadow && Build.VERSION.SDK_INT >= 21 && !target.transparentTarget) {
      outlineProvider = new ViewOutlineProvider() {
        @TargetApi(Build.VERSION_CODES.LOLLIPOP)
        @Override
        public void getOutline(View view, Outline outline) {
          if (outerCircleCenter == null) return;
          outline.setOval(
              (int) (outerCircleCenter[0] - outerCircleRadius), (int) (outerCircleCenter[1] - outerCircleRadius),
              (int) (outerCircleCenter[0] + outerCircleRadius), (int) (outerCircleCenter[1] + outerCircleRadius));
          outline.setAlpha(outerCircleAlpha / 255.0f);
          if (Build.VERSION.SDK_INT >= 22) {
            outline.offset(0, SHADOW_DIM);
          }
        }
      };

      setOutlineProvider(outlineProvider);
      setElevation(SHADOW_DIM);
    }

    if (shouldDrawShadow && outlineProvider == null && Build.VERSION.SDK_INT < 18) {
      setLayerType(LAYER_TYPE_SOFTWARE, null);
    } else {
      setLayerType(LAYER_TYPE_HARDWARE, null);
    }

    final Resources.Theme theme = context.getTheme();
    isDark = UiUtil.themeIntAttr(context, "isLightTheme") == 0;

    final Integer outerCircleColor = target.outerCircleColorInt(context);
    if (outerCircleColor != null) {
      outerCirclePaint.setColor(outerCircleColor);
    } else if (theme != null) {
      outerCirclePaint.setColor(UiUtil.themeIntAttr(context, "colorPrimary"));
    } else {
      outerCirclePaint.setColor(Color.WHITE);
    }

    final Integer targetCircleColor = target.targetCircleColorInt(context);
    if (targetCircleColor != null) {
      targetCirclePaint.setColor(targetCircleColor);
    } else {
      targetCirclePaint.setColor(isDark ? Color.BLACK : Color.WHITE);
    }

    if (target.transparentTarget) {
      targetCirclePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
    }

    targetCirclePulsePaint.setColor(targetCirclePaint.getColor());

    final Integer targetDimColor = target.dimColorInt(context);
    if (targetDimColor != null) {
      dimColor = UiUtil.setAlpha(targetDimColor, 0.3f);
    } else {
      dimColor = -1;
    }

    final Integer titleTextColor = target.titleTextColorInt(context);
    if (titleTextColor != null) {
      titlePaint.setColor(titleTextColor);
    } else {
      titlePaint.setColor(isDark ? Color.BLACK : Color.WHITE);
    }

    final Integer descriptionTextColor = target.descriptionTextColorInt(context);
    if (descriptionTextColor != null) {
      descriptionPaint.setColor(descriptionTextColor);
    } else {
      descriptionPaint.setColor(titlePaint.getColor());
    }

    if (target.titleTypeface != null) {
      titlePaint.setTypeface(target.titleTypeface);
    }

    if (target.descriptionTypeface != null) {
      descriptionPaint.setTypeface(target.descriptionTypeface);
    }
  }

  @Override
  protected void onDetachedFromWindow() {
    super.onDetachedFromWindow();
    onDismiss(false);
  }

  void onDismiss(boolean userInitiated) {
    if (isDismissed) return;

    isDismissing = false;
    isDismissed = true;

    for (final ValueAnimator animator : animators) {
      animator.cancel();
      animator.removeAllUpdateListeners();
    }

    ViewUtil.removeOnGlobalLayoutListener(getViewTreeObserver(), globalLayoutListener);
    visible = false;

    if (listener != null) {
      listener.onTargetDismissed(this, userInitiated);
    }
  }

  @Override
  protected void onDraw(Canvas c) {
    if (isDismissed || outerCircleCenter == null) return;

    if (topBoundary > 0 && bottomBoundary > 0) {
      c.clipRect(0, topBoundary, getWidth(), bottomBoundary);
    }

    if (dimColor != -1) {
      c.drawColor(dimColor);
    }

    int saveCount;
    outerCirclePaint.setAlpha(outerCircleAlpha);
    if (shouldDrawShadow && outlineProvider == null) {
      saveCount = c.save();
      {
        c.clipPath(outerCirclePath, Region.Op.DIFFERENCE);
        drawJitteredShadow(c);
      }
      c.restoreToCount(saveCount);
    }
    c.drawCircle(outerCircleCenter[0], outerCircleCenter[1], outerCircleRadius, outerCirclePaint);

    targetCirclePaint.setAlpha(targetCircleAlpha);
    if (targetCirclePulseAlpha > 0) {
      targetCirclePulsePaint.setAlpha(targetCirclePulseAlpha);
      c.drawCircle(targetBounds.centerX(), targetBounds.centerY(),
          targetCirclePulseRadius, targetCirclePulsePaint);
    }
    c.drawCircle(targetBounds.centerX(), targetBounds.centerY(),
        targetCircleRadius, targetCirclePaint);

    saveCount = c.save();
    {
      c.translate(textBounds.left, textBounds.top);
      titlePaint.setAlpha(textAlpha);
      if (titleLayout != null) {
        titleLayout.draw(c);
      }

      if (descriptionLayout != null && titleLayout != null) {
        c.translate(0, titleLayout.getHeight() + TEXT_SPACING);
        descriptionPaint.setAlpha((int) (target.descriptionTextAlpha * textAlpha));
        descriptionLayout.draw(c);
      }
    }
    c.restoreToCount(saveCount);

    saveCount = c.save();
    {
      if (tintedTarget != null) {
        c.translate(targetBounds.centerX() - tintedTarget.getWidth() / 2,
            targetBounds.centerY() - tintedTarget.getHeight() / 2);
        c.drawBitmap(tintedTarget, 0, 0, targetCirclePaint);
      } else if (target.icon != null) {
        c.translate(targetBounds.centerX() - target.icon.getBounds().width() / 2,
            targetBounds.centerY() - target.icon.getBounds().height() / 2);
        target.icon.setAlpha(targetCirclePaint.getAlpha());
        target.icon.draw(c);
      }
    }
    c.restoreToCount(saveCount);

    if (debug) {
      drawDebugInformation(c);
    }
  }

  @Override
  public boolean onTouchEvent(MotionEvent e) {
    lastTouchX = e.getX();
    lastTouchY = e.getY();
    return super.onTouchEvent(e);
  }

  @Override
  public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (isVisible() && cancelable && keyCode == KeyEvent.KEYCODE_BACK) {
      event.startTracking();
      return true;
    }

    return false;
  }

  @Override
  public boolean onKeyUp(int keyCode, KeyEvent event) {
    if (isVisible() && isInteractable && cancelable
        && keyCode == KeyEvent.KEYCODE_BACK && event.isTracking() && !event.isCanceled()) {
      isInteractable = false;

      if (listener != null) {
        listener.onTargetCancel(this);
      } else {
        new Listener().onTargetCancel(this);
      }

      return true;
    }

    return false;
  }

  /**
   * Dismiss this view
   * @param tappedTarget If the user tapped the target or not
   *                     (results in different dismiss animations)
   */
  public void dismiss(boolean tappedTarget) {
    isDismissing = true;
    pulseAnimation.cancel();
    expandAnimation.cancel();
    if (!visible || outerCircleCenter == null) {
      finishDismiss(tappedTarget);
      return;
    }
    if (tappedTarget) {
      dismissConfirmAnimation.start();
    } else {
      dismissAnimation.start();
    }
  }

  private void finishDismiss(boolean userInitiated) {
    onDismiss(userInitiated);
    ViewUtil.removeView(parent, TapTargetView.this);
  }

  /** Specify whether to draw a wireframe around the view, useful for debugging **/
  public void setDrawDebug(boolean status) {
    if (debug != status) {
      debug = status;
      postInvalidate();
    }
  }

  /** Returns whether this view is visible or not **/
  public boolean isVisible() {
    return !isDismissed && visible;
  }

  void drawJitteredShadow(Canvas c) {
    final float baseAlpha = 0.20f * outerCircleAlpha;
    outerCircleShadowPaint.setStyle(Paint.Style.FILL_AND_STROKE);
    outerCircleShadowPaint.setAlpha((int) baseAlpha);
    c.drawCircle(outerCircleCenter[0], outerCircleCenter[1] + SHADOW_DIM, outerCircleRadius, outerCircleShadowPaint);
    outerCircleShadowPaint.setStyle(Paint.Style.STROKE);
    final int numJitters = 7;
    for (int i = numJitters - 1; i > 0; --i) {
      outerCircleShadowPaint.setAlpha((int) ((i / (float) numJitters) * baseAlpha));
      c.drawCircle(outerCircleCenter[0], outerCircleCenter[1] + SHADOW_DIM ,
          outerCircleRadius + (numJitters - i) * SHADOW_JITTER_DIM , outerCircleShadowPaint);
    }
  }

  void drawDebugInformation(Canvas c) {
    if (debugPaint == null) {
      debugPaint = new Paint();
      debugPaint.setARGB(255, 255, 0, 0);
      debugPaint.setStyle(Paint.Style.STROKE);
      debugPaint.setStrokeWidth(UiUtil.dp(getContext(), 1));
    }

    if (debugTextPaint == null) {
      debugTextPaint = new TextPaint();
      debugTextPaint.setColor(0xFFFF0000);
      debugTextPaint.setTextSize(UiUtil.sp(getContext(), 16));
    }

    // Draw wireframe
    debugPaint.setStyle(Paint.Style.STROKE);
    c.drawRect(textBounds, debugPaint);
    c.drawRect(targetBounds, debugPaint);
    c.drawCircle(outerCircleCenter[0], outerCircleCenter[1], 10, debugPaint);
    c.drawCircle(outerCircleCenter[0], outerCircleCenter[1], calculatedOuterCircleRadius - CIRCLE_PADDING, debugPaint);
    c.drawCircle(targetBounds.centerX(), targetBounds.centerY(), TARGET_RADIUS + TARGET_PADDING, debugPaint);

    // Draw positions and dimensions
    debugPaint.setStyle(Paint.Style.FILL);
    final String debugText =
            "Text bounds: " + textBounds.toShortString() + "\n" +
            "Target bounds: " + targetBounds.toShortString() + "\n" +
            "Center: " + outerCircleCenter[0] + " " + outerCircleCenter[1] + "\n" +
            "View size: " + getWidth() + " " + getHeight() + "\n" +
            "Target bounds: " + targetBounds.toShortString();

    if (debugStringBuilder == null) {
      debugStringBuilder = new SpannableStringBuilder(debugText);
    } else {
      debugStringBuilder.clear();
      debugStringBuilder.append(debugText);
    }

    if (debugLayout == null) {
      debugLayout = new DynamicLayout(debugText, debugTextPaint, getWidth(), Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);
    }

    final int saveCount = c.save();
    {
      debugPaint.setARGB(220, 0, 0, 0);
      c.translate(0.0f, topBoundary);
      c.drawRect(0.0f, 0.0f, debugLayout.getWidth(), debugLayout.getHeight(), debugPaint);
      debugPaint.setARGB(255, 255, 0, 0);
      debugLayout.draw(c);
    }
    c.restoreToCount(saveCount);
  }

  void drawTintedTarget() {
    final Drawable icon = target.icon;
    if (!shouldTintTarget || icon == null) {
      tintedTarget = null;
      return;
    }

    if (tintedTarget != null) return;

    tintedTarget = Bitmap.createBitmap(icon.getIntrinsicWidth(), icon.getIntrinsicHeight(),
        Bitmap.Config.ARGB_8888);
    final Canvas canvas = new Canvas(tintedTarget);
    icon.setColorFilter(new PorterDuffColorFilter(
        outerCirclePaint.getColor(), PorterDuff.Mode.SRC_ATOP));
    icon.draw(canvas);
    icon.setColorFilter(null);
  }

  void updateTextLayouts() {
    final int textWidth = Math.min(getWidth(), TEXT_MAX_WIDTH) - TEXT_PADDING * 2;
    if (textWidth <= 0) {
      return;
    }

    titleLayout = new StaticLayout(title, titlePaint, textWidth,
            Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);

    if (description != null) {
      descriptionLayout = new StaticLayout(description, descriptionPaint, textWidth,
              Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);
    } else {
      descriptionLayout = null;
    }
  }

  float halfwayLerp(float lerp) {
    if (lerp < 0.5f) {
      return lerp / 0.5f;
    }

    return (1.0f - lerp) / 0.5f;
  }

  float delayedLerp(float lerp, float threshold) {
    if (lerp < threshold) {
      return 0.0f;
    }

    return (lerp - threshold) / (1.0f - threshold);
  }

  void calculateDimensions() {
    textBounds = getTextBounds();
    outerCircleCenter = getOuterCircleCenterPoint();
    calculatedOuterCircleRadius = getOuterCircleRadius(outerCircleCenter[0], outerCircleCenter[1], textBounds, targetBounds);
  }

  void calculateDrawingBounds() {
    if (outerCircleCenter == null) {
      // Called dismiss before we got a chance to display the tap target
      // So we have no center -> cant determine the drawing bounds
      return;
    }
    drawingBounds.left = (int) Math.max(0, outerCircleCenter[0] - outerCircleRadius);
    drawingBounds.top = (int) Math.min(0, outerCircleCenter[1] - outerCircleRadius);
    drawingBounds.right = (int) Math.min(getWidth(),
        outerCircleCenter[0] + outerCircleRadius + CIRCLE_PADDING);
    drawingBounds.bottom = (int) Math.min(getHeight(),
        outerCircleCenter[1] + outerCircleRadius + CIRCLE_PADDING);
  }

  int getOuterCircleRadius(int centerX, int centerY, Rect textBounds, Rect targetBounds) {
    final int targetCenterX = targetBounds.centerX();
    final int targetCenterY = targetBounds.centerY();
    final int expandedRadius = (int) (1.1f * TARGET_RADIUS);
    final Rect expandedBounds = new Rect(targetCenterX, targetCenterY, targetCenterX, targetCenterY);
    expandedBounds.inset(-expandedRadius, -expandedRadius);

    final int textRadius = maxDistanceToPoints(centerX, centerY, textBounds);
    final int targetRadius = maxDistanceToPoints(centerX, centerY, expandedBounds);
    return Math.max(textRadius, targetRadius) + CIRCLE_PADDING;
  }

  Rect getTextBounds() {
    final int totalTextHeight = getTotalTextHeight();
    final int totalTextWidth = getTotalTextWidth();

    final int possibleTop = targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight;
    final int top;
    if (possibleTop > topBoundary) {
      top = possibleTop;
    } else {
      top = targetBounds.centerY() + TARGET_RADIUS + TARGET_PADDING;
    }

    final int relativeCenterDistance = (getWidth() / 2) - targetBounds.centerX();
    final int bias = relativeCenterDistance < 0 ? -TEXT_POSITIONING_BIAS : TEXT_POSITIONING_BIAS;
    final int left = Math.max(TEXT_PADDING, targetBounds.centerX() - bias - totalTextWidth);
    final int right = Math.min(getWidth() - TEXT_PADDING, left + totalTextWidth);
    return new Rect(left, top, right, top + totalTextHeight);
  }

  int[] getOuterCircleCenterPoint() {
    if (inGutter(targetBounds.centerY())) {
      return new int[]{targetBounds.centerX(), targetBounds.centerY()};
    }

    final int targetRadius = Math.max(targetBounds.width(), targetBounds.height()) / 2 + TARGET_PADDING;
    final int totalTextHeight = getTotalTextHeight();

    final boolean onTop = targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight > 0;

    final int left = Math.min(textBounds.left, targetBounds.left - targetRadius);
    final int right = Math.max(textBounds.right, targetBounds.right + targetRadius);
    final int titleHeight = titleLayout == null ? 0 : titleLayout.getHeight();
    final int centerY = onTop ?
        targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight + titleHeight
        :
        targetBounds.centerY() + TARGET_RADIUS + TARGET_PADDING + titleHeight;

    return new int[] { (left + right) / 2, centerY };
  }

  int getTotalTextHeight() {
    if (titleLayout == null) {
      return 0;
    }

    if (descriptionLayout == null) {
      return titleLayout.getHeight() + TEXT_SPACING;
    }

    return titleLayout.getHeight() + descriptionLayout.getHeight() + TEXT_SPACING;
  }

  int getTotalTextWidth() {
    if (titleLayout == null) {
      return 0;
    }

    if (descriptionLayout == null) {
      return titleLayout.getWidth();
    }

    return Math.max(titleLayout.getWidth(), descriptionLayout.getWidth());
  }

  boolean inGutter(int y) {
    if (bottomBoundary > 0) {
      return y < GUTTER_DIM || y > bottomBoundary - GUTTER_DIM;
    } else {
      return y < GUTTER_DIM || y > getHeight() - GUTTER_DIM;
    }
  }

  int maxDistanceToPoints(int x1, int y1, Rect bounds) {
    final double tl = distance(x1, y1, bounds.left, bounds.top);
    final double tr = distance(x1, y1, bounds.right, bounds.top);
    final double bl = distance(x1, y1, bounds.left, bounds.bottom);
    final double br = distance(x1, y1, bounds.right, bounds.bottom);
    return (int) Math.max(tl, Math.max(tr, Math.max(bl, br)));
  }

  double distance(int x1, int y1, int x2, int y2) {
    return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
  }

  void invalidateViewAndOutline(Rect bounds) {
    invalidate(bounds);
    if (outlineProvider != null && Build.VERSION.SDK_INT >= 21) {
      invalidateOutline();
    }
  }
}