/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * 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 com.android.incallui.autoresizetext;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.RectF;
import android.support.annotation.Nullable;
import android.text.Layout.Alignment;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.util.SparseIntArray;
import android.util.TypedValue;
import android.widget.TextView;

/**
 * A TextView that automatically scales its text to completely fill its allotted width.
 *
 * <p>Note: In some edge cases, the binary search algorithm to find the best fit may slightly
 * overshoot / undershoot its constraints. See a bug. No minimal repro case has been
 * found yet. A known workaround is the solution provided on StackOverflow:
 * http://stackoverflow.com/a/5535672
 */
public class AutoResizeTextView extends TextView {
  private static final int NO_LINE_LIMIT = -1;
  private static final float DEFAULT_MIN_TEXT_SIZE = 16.0f;
  private static final int DEFAULT_RESIZE_STEP_UNIT = TypedValue.COMPLEX_UNIT_PX;

  private final DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
  private final RectF availableSpaceRect = new RectF();
  private final SparseIntArray textSizesCache = new SparseIntArray();
  private final TextPaint textPaint = new TextPaint();
  private int resizeStepUnit = DEFAULT_RESIZE_STEP_UNIT;
  private float minTextSize = DEFAULT_MIN_TEXT_SIZE;
  private float maxTextSize;
  private int maxWidth;

  public AutoResizeTextView(Context context) {
    super(context, null, 0);
    initialize(context, null, 0, 0);
  }

  public AutoResizeTextView(Context context, AttributeSet attrs) {
    super(context, attrs, 0);
    initialize(context, attrs, 0, 0);
  }

  public AutoResizeTextView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    initialize(context, attrs, defStyleAttr, 0);
  }

  public AutoResizeTextView(
      Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    super(context, attrs, defStyleAttr, defStyleRes);
    initialize(context, attrs, defStyleAttr, defStyleRes);
  }

  private void initialize(
      Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    TypedArray typedArray = context.getTheme().obtainStyledAttributes(
        attrs, R.styleable.AutoResizeTextView, defStyleAttr, defStyleRes);
    readAttrs(typedArray);
    typedArray.recycle();
    textPaint.set(getPaint());
  }

  /**
   * Although this overrides the setTextSize method from the TextView base class, it changes the
   * semantics a bit: Calling setTextSize now specifies the maximum text size to be used by this
   * view. If the text can't fit with that text size, the text size will be scaled down, up to the
   * minimum text size specified in {@link #setMinTextSize}.
   *
   * <p>Note that the final size unit will be truncated to the nearest integer value of the
   * specified unit.
   */
  @Override
  public final void setTextSize(int unit, float size) {
    float maxTextSize = TypedValue.applyDimension(unit, size, displayMetrics);
    if (this.maxTextSize != maxTextSize) {
      this.maxTextSize = maxTextSize;
      // TODO(tobyj): It's not actually necessary to clear the whole cache here. To optimize cache
      // deletion we'd have to delete all entries in the cache with a value equal or larger than
      // MIN(old_max_size, new_max_size) when changing maxTextSize; and all entries with a value
      // equal or smaller than MAX(old_min_size, new_min_size) when changing minTextSize.
      textSizesCache.clear();
      requestLayout();
    }
  }

  /**
   * Sets the lower text size limit and invalidate the view.
   *
   * <p>The parameters follow the same behavior as they do in {@link #setTextSize}.
   *
   * <p>Note that the final size unit will be truncated to the nearest integer value of the
   * specified unit.
   */
  public final void setMinTextSize(int unit, float size) {
    float minTextSize = TypedValue.applyDimension(unit, size, displayMetrics);
    if (this.minTextSize != minTextSize) {
      this.minTextSize = minTextSize;
      textSizesCache.clear();
      requestLayout();
    }
  }

  /**
   * Sets the unit to use as step units when computing the resized font size. This view's text
   * contents will always be rendered as a whole integer value in the unit specified here. For
   * example, if the unit is {@link TypedValue#COMPLEX_UNIT_SP}, then the text size may end up
   * being 13sp or 14sp, but never 13.5sp.
   *
   * <p>By default, the AutoResizeTextView uses the unit {@link TypedValue#COMPLEX_UNIT_PX}.
   *
   * @param unit the unit type to use; must be a known unit type from {@link TypedValue}.
   */
  public final void setResizeStepUnit(int unit) {
    if (resizeStepUnit != unit) {
      resizeStepUnit = unit;
      requestLayout();
    }
  }

  private void readAttrs(TypedArray typedArray) {
    resizeStepUnit = typedArray.getInt(
        R.styleable.AutoResizeTextView_autoResizeText_resizeStepUnit, DEFAULT_RESIZE_STEP_UNIT);
    minTextSize = (int) typedArray.getDimension(
        R.styleable.AutoResizeTextView_autoResizeText_minTextSize, DEFAULT_MIN_TEXT_SIZE);
    maxTextSize = (int) getTextSize();
  }

  private void adjustTextSize() {
    int maxWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
    int maxHeight = getMeasuredHeight() - getPaddingBottom() - getPaddingTop();

    if (maxWidth <= 0 || maxHeight <= 0) {
      return;
    }

    this.maxWidth = maxWidth;
    availableSpaceRect.right = maxWidth;
    availableSpaceRect.bottom = maxHeight;
    int minSizeInStepSizeUnits = (int) Math.ceil(convertToResizeStepUnits(minTextSize));
    int maxSizeInStepSizeUnits = (int) Math.floor(convertToResizeStepUnits(maxTextSize));
    float textSize = computeTextSize(
        minSizeInStepSizeUnits, maxSizeInStepSizeUnits, availableSpaceRect);
    super.setTextSize(resizeStepUnit, textSize);
  }

  private boolean suggestedSizeFitsInSpace(float suggestedSizeInPx, RectF availableSpace) {
    textPaint.setTextSize(suggestedSizeInPx);
    String text = getText().toString();
    int maxLines = getMaxLines();
    if (maxLines == 1) {
      // If single line, check the line's height and width.
      return textPaint.getFontSpacing() <= availableSpace.bottom
          && textPaint.measureText(text) <= availableSpace.right;
    } else {
      // If multiline, lay the text out, then check the number of lines, the layout's height,
      // and each line's width.
      StaticLayout layout = new StaticLayout(text,
          textPaint,
          maxWidth,
          Alignment.ALIGN_NORMAL,
          getLineSpacingMultiplier(),
          getLineSpacingExtra(),
          true);

      // Return false if we need more than maxLines. The text is obviously too big in this case.
      if (maxLines != NO_LINE_LIMIT && layout.getLineCount() > maxLines) {
        return false;
      }
      // Return false if the height of the layout is too big.
      return layout.getHeight() <= availableSpace.bottom;
    }
  }

  /**
   * Computes the final text size to use for this text view, factoring in any previously
   * cached computations.
   *
   * @param minSize the minimum text size to allow, in units of {@link #resizeStepUnit}
   * @param maxSize the maximum text size to allow, in units of {@link #resizeStepUnit}
   */
  private float computeTextSize(int minSize, int maxSize, RectF availableSpace) {
    CharSequence text = getText();
    if (text != null && textSizesCache.get(text.hashCode()) != 0) {
      return textSizesCache.get(text.hashCode());
    }
    int size = binarySearchSizes(minSize, maxSize, availableSpace);
    textSizesCache.put(text == null ? 0 : text.hashCode(), size);
    return size;
  }

  /**
   * Performs a binary search to find the largest font size that will still fit within the size
   * available to this view.
   * @param minSize the minimum text size to allow, in units of {@link #resizeStepUnit}
   * @param maxSize the maximum text size to allow, in units of {@link #resizeStepUnit}
   */
  private int binarySearchSizes(int minSize, int maxSize, RectF availableSpace) {
    int bestSize = minSize;
    int low = minSize + 1;
    int high = maxSize;
    int sizeToTry;
    while (low <= high) {
      sizeToTry = (low + high) / 2;
      float dimension = TypedValue.applyDimension(resizeStepUnit, sizeToTry, displayMetrics);
      if (suggestedSizeFitsInSpace(dimension, availableSpace)) {
        bestSize = low;
        low = sizeToTry + 1;
      } else {
        high = sizeToTry - 1;
        bestSize = high;
      }
    }
    return bestSize;
  }

  private float convertToResizeStepUnits(float dimension) {
    // To figure out the multiplier between a raw dimension and the resizeStepUnit, we invert the
    // conversion of 1 resizeStepUnit to a raw dimension.
    float multiplier = 1 / TypedValue.applyDimension(resizeStepUnit, 1, displayMetrics);
    return dimension * multiplier;
  }

  @Override
  protected final void onTextChanged(
      final CharSequence text, final int start, final int before, final int after) {
    super.onTextChanged(text, start, before, after);
    adjustTextSize();
  }

  @Override
  protected final void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
    super.onSizeChanged(width, height, oldWidth, oldHeight);
    if (width != oldWidth || height != oldHeight) {
      textSizesCache.clear();
      adjustTextSize();
    }
  }

  @Override
  protected final void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    adjustTextSize();
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
  }
}
