/*
 * Copyright 2022 Google LLC
 *
 * 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.google.ux.material.libmonet.temperature;

import com.google.ux.material.libmonet.hct.Hct;
import com.google.ux.material.libmonet.utils.ColorUtils;
import com.google.ux.material.libmonet.utils.MathUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Design utilities using color temperature theory.
 *
 * <p>Analogous colors, complementary color, and cache to efficiently, lazily, generate data for
 * calculations when needed.
 */
public final class TemperatureCache {
  private final Hct input;

  private Hct precomputedComplement;
  private List<Hct> precomputedHctsByTemp;
  private List<Hct> precomputedHctsByHue;
  private Map<Hct, Double> precomputedTempsByHct;

  private TemperatureCache() {
    throw new UnsupportedOperationException();
  }

  /**
   * Create a cache that allows calculation of ex. complementary and analogous colors.
   *
   * @param input Color to find complement/analogous colors of. Any colors will have the same tone,
   * and chroma as the input color, modulo any restrictions due to the other hues having lower 
   * limits on chroma.
   */
  public TemperatureCache(Hct input) {
    this.input = input;
  }

  /**
   * A color that complements the input color aesthetically.
   *
   * <p>In art, this is usually described as being across the color wheel. History of this shows
   * intent as a color that is just as cool-warm as the input color is warm-cool.
   */
  public Hct getComplement() {
    if (precomputedComplement != null) {
      return precomputedComplement;
    }

    double coldestHue = getColdest().getHue();
    double coldestTemp = getTempsByHct().get(getColdest());

    double warmestHue = getWarmest().getHue();
    double warmestTemp = getTempsByHct().get(getWarmest());
    double range = warmestTemp - coldestTemp;
    boolean startHueIsColdestToWarmest = isBetween(input.getHue(), coldestHue, warmestHue);
    double startHue = startHueIsColdestToWarmest ? warmestHue : coldestHue;
    double endHue = startHueIsColdestToWarmest ? coldestHue : warmestHue;
    double directionOfRotation = 1.;
    double smallestError = 1000.;
    Hct answer = getHctsByHue().get((int) Math.round(input.getHue()));

    double complementRelativeTemp = (1. - getRelativeTemperature(input));
    // Find the color in the other section, closest to the inverse percentile
    // of the input color. This is the complement.
    for (double hueAddend = 0.; hueAddend <= 360.; hueAddend += 1.) {
      double hue = MathUtils.sanitizeDegreesDouble(
          startHue + directionOfRotation * hueAddend);
      if (!isBetween(hue, startHue, endHue)) {
        continue;
      }
      Hct possibleAnswer = getHctsByHue().get((int) Math.round(hue));
      double relativeTemp =
          (getTempsByHct().get(possibleAnswer) - coldestTemp) / range;
      double error = Math.abs(complementRelativeTemp - relativeTemp);
      if (error < smallestError) {
        smallestError = error;
        answer = possibleAnswer;
      }
    }
    precomputedComplement = answer;
    return precomputedComplement;
  }

  /**
   * 5 colors that pair well with the input color.
   *
   * <p>The colors are equidistant in temperature and adjacent in hue.
   */
  public List<Hct> getAnalogousColors() {
    return getAnalogousColors(5, 12);
  }

  /**
   * A set of colors with differing hues, equidistant in temperature.
   *
   * <p>In art, this is usually described as a set of 5 colors on a color wheel divided into 12
   * sections. This method allows provision of either of those values.
   *
   * <p>Behavior is undefined when count or divisions is 0. When divisions < count, colors repeat.
   *
   * @param count The number of colors to return, includes the input color.
   * @param divisions The number of divisions on the color wheel.
   */
  public List<Hct> getAnalogousColors(int count, int divisions) {
    // The starting hue is the hue of the input color.
    int startHue = (int) Math.round(input.getHue());
    Hct startHct = getHctsByHue().get(startHue);
    double lastTemp = getRelativeTemperature(startHct);

    List<Hct> allColors = new ArrayList<>();
    allColors.add(startHct);

    double absoluteTotalTempDelta = 0.f;
    for (int i = 0; i < 360; i++) {
      int hue = MathUtils.sanitizeDegreesInt(startHue + i);
      Hct hct = getHctsByHue().get(hue);
      double temp = getRelativeTemperature(hct);
      double tempDelta = Math.abs(temp - lastTemp);
      lastTemp = temp;
      absoluteTotalTempDelta += tempDelta;
    }

    int hueAddend = 1;
    double tempStep = absoluteTotalTempDelta / (double) divisions;
    double totalTempDelta = 0.0;
    lastTemp = getRelativeTemperature(startHct);
    while (allColors.size() < divisions) {
      int hue = MathUtils.sanitizeDegreesInt(startHue + hueAddend);
      Hct hct = getHctsByHue().get(hue);
      double temp = getRelativeTemperature(hct);
      double tempDelta = Math.abs(temp - lastTemp);
      totalTempDelta += tempDelta;

      double desiredTotalTempDeltaForIndex = (allColors.size() * tempStep);
      boolean indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex;
      int indexAddend = 1;
      // Keep adding this hue to the answers until its temperature is
      // insufficient. This ensures consistent behavior when there aren't
      // `divisions` discrete steps between 0 and 360 in hue with `tempStep`
      // delta in temperature between them.
      //
      // For example, white and black have no analogues: there are no other
      // colors at T100/T0. Therefore, they should just be added to the array
      // as answers.
      while (indexSatisfied && allColors.size() < divisions) {
        allColors.add(hct);
        desiredTotalTempDeltaForIndex = ((allColors.size() + indexAddend) * tempStep);
        indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex;
        indexAddend++;
      }
      lastTemp = temp;
      hueAddend++;

      if (hueAddend > 360) {
        while (allColors.size() < divisions) {
          allColors.add(hct);
        }
        break;
      }
    }

    List<Hct> answers = new ArrayList<>();
    answers.add(input);

    int ccwCount = (int) Math.floor(((double) count - 1.0) / 2.0);
    for (int i = 1; i < (ccwCount + 1); i++) {
      int index = 0 - i;
      while (index < 0) {
        index = allColors.size() + index;
      }
      if (index >= allColors.size()) {
        index = index % allColors.size();
      }
      answers.add(0, allColors.get(index));
    }

    int cwCount = count - ccwCount - 1;
    for (int i = 1; i < (cwCount + 1); i++) {
      int index = i;
      while (index < 0) {
        index = allColors.size() + index;
      }
      if (index >= allColors.size()) {
        index = index % allColors.size();
      }
      answers.add(allColors.get(index));
    }

    return answers;
  }

  /**
   * Temperature relative to all colors with the same chroma and tone.
   *
   * @param hct HCT to find the relative temperature of.
   * @return Value on a scale from 0 to 1.
   */
  public double getRelativeTemperature(Hct hct) {
    double range = getTempsByHct().get(getWarmest()) - getTempsByHct().get(getColdest());
    double differenceFromColdest =
        getTempsByHct().get(hct) - getTempsByHct().get(getColdest());
    // Handle when there's no difference in temperature between warmest and
    // coldest: for example, at T100, only one color is available, white.
    if (range == 0.) {
      return 0.5;
    }
    return differenceFromColdest / range;
  }

  /**
   * Value representing cool-warm factor of a color. Values below 0 are considered cool, above,
   * warm.
   *
   * <p>Color science has researched emotion and harmony, which art uses to select colors. Warm-cool
   * is the foundation of analogous and complementary colors. See: - Li-Chen Ou's Chapter 19 in
   * Handbook of Color Psychology (2015). - Josef Albers' Interaction of Color chapters 19 and 21.
   *
   * <p>Implementation of Ou, Woodcock and Wright's algorithm, which uses Lab/LCH color space.
   * Return value has these properties:<br>
   * - Values below 0 are cool, above 0 are warm.<br>
   * - Lower bound: -9.66. Chroma is infinite. Assuming max of Lab chroma 130.<br>
   * - Upper bound: 8.61. Chroma is infinite. Assuming max of Lab chroma 130.
   */
  public static double rawTemperature(Hct color) {
    double[] lab = ColorUtils.labFromArgb(color.toInt());
    double hue = MathUtils.sanitizeDegreesDouble(Math.toDegrees(Math.atan2(lab[2], lab[1])));
    double chroma = Math.hypot(lab[1], lab[2]);
    return -0.5
        + 0.02
            * Math.pow(chroma, 1.07)
            * Math.cos(Math.toRadians(MathUtils.sanitizeDegreesDouble(hue - 50.)));
  }

  /** Coldest color with same chroma and tone as input. */
  private Hct getColdest() {
    return getHctsByTemp().get(0);
  }

  /**
   * HCTs for all colors with the same chroma/tone as the input.
   *
   * <p>Sorted by hue, ex. index 0 is hue 0.
   */
  private List<Hct> getHctsByHue() {
    if (precomputedHctsByHue != null) {
      return precomputedHctsByHue;
    }
    List<Hct> hcts = new ArrayList<>();
    for (double hue = 0.; hue <= 360.; hue += 1.) {
      Hct colorAtHue = Hct.from(hue, input.getChroma(), input.getTone());
      hcts.add(colorAtHue);
    }
    precomputedHctsByHue = Collections.unmodifiableList(hcts);
    return precomputedHctsByHue;
  }

  /**
   * HCTs for all colors with the same chroma/tone as the input.
   *
   * <p>Sorted from coldest first to warmest last.
   */
  // Prevent lint for Comparator not being available on Android before API level 24, 7.0, 2016.
  // "AndroidJdkLibsChecker" for one linter, "NewApi" for another.
  // A java_library Bazel rule with an Android constraint cannot skip these warnings without this
  // annotation; another solution would be to create an android_library rule and supply
  // AndroidManifest with an SDK set higher than 23.
  @SuppressWarnings({"AndroidJdkLibsChecker", "NewApi"})
  private List<Hct> getHctsByTemp() {
    if (precomputedHctsByTemp != null) {
      return precomputedHctsByTemp;
    }

    List<Hct> hcts = new ArrayList<>(getHctsByHue());
    hcts.add(input);
    Comparator<Hct> temperaturesComparator =
        Comparator.comparing((Hct arg) -> getTempsByHct().get(arg), Double::compareTo);
    Collections.sort(hcts, temperaturesComparator);
    precomputedHctsByTemp = hcts;
    return precomputedHctsByTemp;
  }

  /** Keys of HCTs in getHctsByTemp, values of raw temperature. */
  private Map<Hct, Double> getTempsByHct() {
    if (precomputedTempsByHct != null) {
      return precomputedTempsByHct;
    }

    List<Hct> allHcts = new ArrayList<>(getHctsByHue());
    allHcts.add(input);

    Map<Hct, Double> temperaturesByHct = new HashMap<>();
    for (Hct hct : allHcts) {
      temperaturesByHct.put(hct, rawTemperature(hct));
    }

    precomputedTempsByHct = temperaturesByHct;
    return precomputedTempsByHct;
  }

  /** Warmest color with same chroma and tone as input. */
  private Hct getWarmest() {
    return getHctsByTemp().get(getHctsByTemp().size() - 1);
  }

  /** Determines if an angle is between two other angles, rotating clockwise. */
  private static boolean isBetween(double angle, double a, double b) {
    if (a < b) {
      return a <= angle && angle <= b;
    }
    return a <= angle || angle <= b;
  }
}
