package com.mapbox.services.android.navigation.ui.v5.route;

import android.arch.lifecycle.Lifecycle;
import android.arch.lifecycle.LifecycleObserver;
import android.arch.lifecycle.OnLifecycleEvent;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.support.annotation.ColorInt;
import android.support.annotation.DrawableRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.Size;
import android.support.annotation.StyleRes;
import android.support.v4.content.ContextCompat;
import android.support.v4.graphics.drawable.DrawableCompat;
import android.support.v7.content.res.AppCompatResources;

import com.mapbox.api.directions.v5.models.DirectionsRoute;
import com.mapbox.api.directions.v5.models.RouteLeg;
import com.mapbox.core.constants.Constants;
import com.mapbox.geojson.Feature;
import com.mapbox.geojson.FeatureCollection;
import com.mapbox.geojson.LineString;
import com.mapbox.geojson.Point;
import com.mapbox.mapboxsdk.geometry.LatLng;
import com.mapbox.mapboxsdk.maps.MapView;
import com.mapbox.mapboxsdk.maps.MapboxMap;
import com.mapbox.mapboxsdk.style.expressions.Expression;
import com.mapbox.mapboxsdk.style.layers.Layer;
import com.mapbox.mapboxsdk.style.layers.LineLayer;
import com.mapbox.mapboxsdk.style.layers.Property;
import com.mapbox.mapboxsdk.style.layers.PropertyFactory;
import com.mapbox.mapboxsdk.style.layers.SymbolLayer;
import com.mapbox.mapboxsdk.style.sources.GeoJsonOptions;
import com.mapbox.mapboxsdk.style.sources.GeoJsonSource;
import com.mapbox.mapboxsdk.utils.MathUtils;
import com.mapbox.services.android.navigation.ui.v5.R;
import com.mapbox.services.android.navigation.ui.v5.utils.MapImageUtils;
import com.mapbox.services.android.navigation.ui.v5.utils.MapUtils;
import com.mapbox.services.android.navigation.v5.navigation.MapboxNavigation;
import com.mapbox.services.android.navigation.v5.routeprogress.ProgressChangeListener;
import com.mapbox.services.android.navigation.v5.routeprogress.RouteProgress;
import com.mapbox.turf.TurfConstants;
import com.mapbox.turf.TurfMeasurement;
import com.mapbox.turf.TurfMisc;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;

import static com.mapbox.mapboxsdk.style.expressions.Expression.color;
import static com.mapbox.mapboxsdk.style.expressions.Expression.exponential;
import static com.mapbox.mapboxsdk.style.expressions.Expression.get;
import static com.mapbox.mapboxsdk.style.expressions.Expression.interpolate;
import static com.mapbox.mapboxsdk.style.expressions.Expression.linear;
import static com.mapbox.mapboxsdk.style.expressions.Expression.literal;
import static com.mapbox.mapboxsdk.style.expressions.Expression.match;
import static com.mapbox.mapboxsdk.style.expressions.Expression.step;
import static com.mapbox.mapboxsdk.style.expressions.Expression.stop;
import static com.mapbox.mapboxsdk.style.expressions.Expression.zoom;
import static com.mapbox.mapboxsdk.style.layers.Property.ICON_ROTATION_ALIGNMENT_MAP;
import static com.mapbox.mapboxsdk.style.layers.Property.NONE;
import static com.mapbox.mapboxsdk.style.layers.Property.VISIBLE;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconAllowOverlap;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconIgnorePlacement;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.visibility;

/**
 * Provide a route using {@link NavigationMapRoute#addRoutes(List)} and a route will be drawn using
 * runtime styling. The route will automatically be placed below all labels independent of specific
 * style. If the map styles changed when a routes drawn on the map, the route will automatically be
 * redrawn onto the new map style. If during a navigation session, the user gets re-routed, the
 * route line will be redrawn to reflect the new geometry. To remove the route from the map, use
 * {@link NavigationMapRoute#removeRoute()}.
 * <p>
 * You are given the option when first constructing an instance of this class to pass in a style
 * resource. This allows for custom colorizing and line scaling of the route. Inside your
 * applications {@code style.xml} file, you extend {@code <style name="NavigationMapRoute">} and
 * change some or all the options currently offered. If no style files provided in the constructor,
 * the default style will be used.
 *
 * @since 0.4.0
 */
public class NavigationMapRoute implements MapView.OnMapChangedListener,
  MapboxMap.OnMapClickListener, LifecycleObserver {

  private static final String CONGESTION_KEY = "congestion";
  private static final String SOURCE_KEY = "source";
  private static final String INDEX_KEY = "index";

  private static final String GENERIC_ROUTE_SOURCE_ID = "mapbox-navigation-route-source";
  private static final String GENERIC_ROUTE_LAYER_ID = "mapbox-navigation-route-layer";
  private static final String WAYPOINT_SOURCE_ID = "mapbox-navigation-waypoint-source";
  private static final String WAYPOINT_LAYER_ID = "mapbox-navigation-waypoint-layer";
  private static final String ID_FORMAT = "%s-%d";
  private static final String GENERIC_ROUTE_SHIELD_LAYER_ID = "mapbox-navigation-route-shield-layer";
  private static final int TWO_POINTS = 2;
  private static final int THIRTY = 30;
  private static final String ARROW_BEARING = "mapbox-navigation-arrow-bearing";
  private static final String ARROW_SHAFT_SOURCE_ID = "mapbox-navigation-arrow-shaft-source";
  private static final String ARROW_HEAD_SOURCE_ID = "mapbox-navigation-arrow-head-source";
  private static final String ARROW_SHAFT_CASING_LINE_LAYER_ID = "mapbox-navigation-arrow-shaft-casing-layer";
  private static final String ARROW_SHAFT_LINE_LAYER_ID = "mapbox-navigation-arrow-shaft-layer";
  private static final String ARROW_HEAD_ICON = "mapbox-navigation-arrow-head-icon";
  private static final String ARROW_HEAD_ICON_CASING = "mapbox-navigation-arrow-head-icon-casing";
  private static final int MAX_DEGREES = 360;
  private static final String ARROW_HEAD_CASING_LAYER_ID = "mapbox-navigation-arrow-head-casing-layer";
  private static final Float[] ARROW_HEAD_CASING_OFFSET = {0f, -7f};
  private static final String ARROW_HEAD_LAYER_ID = "mapbox-navigation-arrow-head-layer";
  private static final Float[] ARROW_HEAD_OFFSET = {0f, -7f};
  private static final int MIN_ARROW_ZOOM = 10;
  private static final int MAX_ARROW_ZOOM = 22;
  private static final float MIN_ZOOM_ARROW_SHAFT_SCALE = 2.6f;
  private static final float MAX_ZOOM_ARROW_SHAFT_SCALE = 13.0f;
  private static final float MIN_ZOOM_ARROW_SHAFT_CASING_SCALE = 3.4f;
  private static final float MAX_ZOOM_ARROW_SHAFT_CASING_SCALE = 17.0f;
  private static final float MIN_ZOOM_ARROW_HEAD_SCALE = 0.2f;
  private static final float MAX_ZOOM_ARROW_HEAD_SCALE = 0.8f;
  private static final float MIN_ZOOM_ARROW_HEAD_CASING_SCALE = 0.2f;
  private static final float MAX_ZOOM_ARROW_HEAD_CASING_SCALE = 0.8f;
  private static final float OPAQUE = 0.0f;
  private static final int ARROW_HIDDEN_ZOOM_LEVEL = 14;
  private static final float TRANSPARENT = 1.0f;
  private static final String LAYER_ABOVE_UPCOMING_MANEUVER_ARROW = "com.mapbox.annotations.points";

  @StyleRes
  private int styleRes;
  @ColorInt
  private int routeDefaultColor;
  @ColorInt
  private int routeModerateColor;
  @ColorInt
  private int routeSevereColor;
  @ColorInt
  private int alternativeRouteDefaultColor;
  @ColorInt
  private int alternativeRouteModerateColor;
  @ColorInt
  private int alternativeRouteSevereColor;
  @ColorInt
  private int alternativeRouteShieldColor;
  @ColorInt
  private int routeShieldColor;
  @ColorInt
  private int arrowColor;
  @ColorInt
  private int arrowBorderColor;
  @DrawableRes
  private int originWaypointIcon;
  @DrawableRes
  private int destinationWaypointIcon;

  private MapboxNavigation navigation;
  private final MapboxMap mapboxMap;
  private final HashMap<LineString, DirectionsRoute> routeLineStrings;
  private final List<FeatureCollection> featureCollections;
  private final List<DirectionsRoute> directionsRoutes;
  private final List<String> layerIds;
  private final MapView mapView;
  private int primaryRouteIndex;
  private float routeScale;
  private float alternativeRouteScale;
  private String belowLayer;
  private boolean alternativesVisible;
  private OnRouteSelectionChangeListener onRouteSelectionChangeListener;
  private List<Layer> arrowLayers;
  private GeoJsonSource arrowShaftGeoJsonSource;
  private GeoJsonSource arrowHeadGeoJsonSource;
  private Feature arrowShaftGeoJsonFeature = Feature.fromGeometry(Point.fromLngLat(0, 0));
  private Feature arrowHeadGeoJsonFeature = Feature.fromGeometry(Point.fromLngLat(0, 0));
  private ProgressChangeListener progressChangeListener = new MapRouteProgressChangeListener(this);

  /**
   * Construct an instance of {@link NavigationMapRoute}.
   *
   * @param mapView   the MapView to apply the route to
   * @param mapboxMap the MapboxMap to apply route with
   * @since 0.4.0
   */
  public NavigationMapRoute(@NonNull MapView mapView, @NonNull MapboxMap mapboxMap) {
    this(null, mapView, mapboxMap, R.style.NavigationMapRoute);
  }

  /**
   * Construct an instance of {@link NavigationMapRoute}.
   *
   * @param mapView    the MapView to apply the route to
   * @param mapboxMap  the MapboxMap to apply route with
   * @param belowLayer optionally pass in a layer id to place the route line below
   * @since 0.4.0
   */
  public NavigationMapRoute(@NonNull MapView mapView, @NonNull MapboxMap mapboxMap,
                            @Nullable String belowLayer) {
    this(null, mapView, mapboxMap, R.style.NavigationMapRoute, belowLayer);
  }

  /**
   * Construct an instance of {@link NavigationMapRoute}.
   *
   * @param navigation an instance of the {@link MapboxNavigation} object. Passing in null means
   *                   your route won't consider rerouting during a navigation session.
   * @param mapView    the MapView to apply the route to
   * @param mapboxMap  the MapboxMap to apply route with
   * @since 0.4.0
   */
  public NavigationMapRoute(@Nullable MapboxNavigation navigation, @NonNull MapView mapView,
                            @NonNull MapboxMap mapboxMap) {
    this(navigation, mapView, mapboxMap, R.style.NavigationMapRoute);
  }

  /**
   * Construct an instance of {@link NavigationMapRoute}.
   *
   * @param navigation an instance of the {@link MapboxNavigation} object. Passing in null means
   *                   your route won't consider rerouting during a navigation session.
   * @param mapView    the MapView to apply the route to
   * @param mapboxMap  the MapboxMap to apply route with
   * @param belowLayer optionally pass in a layer id to place the route line below
   * @since 0.4.0
   */
  public NavigationMapRoute(@Nullable MapboxNavigation navigation, @NonNull MapView mapView,
                            @NonNull MapboxMap mapboxMap, @Nullable String belowLayer) {
    this(navigation, mapView, mapboxMap, R.style.NavigationMapRoute, belowLayer);
  }

  /**
   * Construct an instance of {@link NavigationMapRoute}.
   *
   * @param navigation an instance of the {@link MapboxNavigation} object. Passing in null means
   *                   your route won't consider rerouting during a navigation session.
   * @param mapView    the MapView to apply the route to
   * @param mapboxMap  the MapboxMap to apply route with
   * @param styleRes   a style resource with custom route colors, scale, etc.
   */
  public NavigationMapRoute(@Nullable MapboxNavigation navigation, @NonNull MapView mapView,
                            @NonNull MapboxMap mapboxMap, @StyleRes int styleRes) {
    this(navigation, mapView, mapboxMap, styleRes, null);
  }

  /**
   * Construct an instance of {@link NavigationMapRoute}.
   *
   * @param navigation an instance of the {@link MapboxNavigation} object. Passing in null means
   *                   your route won't consider rerouting during a navigation session.
   * @param mapView    the MapView to apply the route to
   * @param mapboxMap  the MapboxMap to apply route with
   * @param styleRes   a style resource with custom route colors, scale, etc.
   * @param belowLayer optionally pass in a layer id to place the route line below
   */
  public NavigationMapRoute(@Nullable MapboxNavigation navigation, @NonNull MapView mapView,
                            @NonNull MapboxMap mapboxMap, @StyleRes int styleRes,
                            @Nullable String belowLayer) {
    this.styleRes = styleRes;
    this.mapView = mapView;
    this.mapboxMap = mapboxMap;
    this.navigation = navigation;
    this.belowLayer = belowLayer;
    featureCollections = new ArrayList<>();
    directionsRoutes = new ArrayList<>();
    routeLineStrings = new HashMap<>();
    layerIds = new ArrayList<>();
    initialize();
    addListeners();
  }

  /**
   * Allows adding a single primary route for the user to traverse along. No alternative routes will
   * be drawn on top of the map.
   *
   * @param directionsRoute the directions route which you'd like to display on the map
   * @since 0.4.0
   */
  public void addRoute(DirectionsRoute directionsRoute) {
    List<DirectionsRoute> routes = new ArrayList<>();
    routes.add(directionsRoute);
    addRoutes(routes);
  }

  /**
   * Provide a list of {@link DirectionsRoute}s, the primary route will default to the first route
   * in the directions route list. All other routes in the list will be drawn on the map using the
   * alternative route style.
   *
   * @param directionsRoutes a list of direction routes, first one being the primary and the rest of
   *                         the routes are considered alternatives.
   * @since 0.8.0
   */
  public void addRoutes(@NonNull @Size(min = 1) List<DirectionsRoute> directionsRoutes) {
    clearRoutes();
    this.directionsRoutes.addAll(directionsRoutes);
    primaryRouteIndex = 0;
    alternativesVisible = directionsRoutes.size() > 1;
    generateFeatureCollectionList(directionsRoutes);
    drawRoutes();
    addDirectionWaypoints();
  }

  /**
   * Add a {@link OnRouteSelectionChangeListener} to know which route the user has currently
   * selected as their primary route.
   *
   * @param onRouteSelectionChangeListener a listener which lets you know when the user has changed
   *                                       the primary route and provides the current direction
   *                                       route which the user has selected
   * @since 0.8.0
   */
  public void setOnRouteSelectionChangeListener(
    @Nullable OnRouteSelectionChangeListener onRouteSelectionChangeListener) {
    this.onRouteSelectionChangeListener = onRouteSelectionChangeListener;
  }

  /**
   * Toggle whether or not you'd like the map to display the alternative routes. This options great
   * for when the user actually begins the navigation session and alternative routes aren't needed
   * anymore.
   *
   * @param alternativesVisible true if you'd like alternative routes to be displayed on the map,
   *                            else false
   * @since 0.8.0
   */
  public void showAlternativeRoutes(boolean alternativesVisible) {
    this.alternativesVisible = alternativesVisible;
    toggleAlternativeVisibility(alternativesVisible);
  }

  public void addProgressChangeListener(MapboxNavigation navigation) {
    this.navigation = navigation;
    navigation.addProgressChangeListener(progressChangeListener);
  }

  public void removeProgressChangeListener(MapboxNavigation navigation) {
    if (navigation != null) {
      navigation.removeProgressChangeListener(progressChangeListener);
    }
  }

  void addUpcomingManeuverArrow(RouteProgress routeProgress) {
    boolean invalidUpcomingStepPoints = routeProgress.upcomingStepPoints() == null
      || routeProgress.upcomingStepPoints().size() < TWO_POINTS;
    boolean invalidCurrentStepPoints = routeProgress.currentStepPoints().size() < TWO_POINTS;
    if (invalidUpcomingStepPoints || invalidCurrentStepPoints) {
      updateArrowLayersVisibilityTo(false);
      return;
    }
    updateArrowLayersVisibilityTo(true);

    List<Point> maneuverPoints = obtainArrowPointsFrom(routeProgress);

    updateArrowShaftWith(maneuverPoints);
    updateArrowHeadWith(maneuverPoints);
  }

  List<DirectionsRoute> retrieveDirectionsRoutes() {
    return directionsRoutes;
  }

  int retrievePrimaryRouteIndex() {
    return primaryRouteIndex;
  }

  //
  // Private methods
  //

  /**
   * Loops through all the route layers stored inside the layerId list and toggles the visibility.
   * if the layerId matches the primary route index, we skip since we still want that route to be
   * displayed.
   */
  private void toggleAlternativeVisibility(boolean visible) {
    for (String layerId : layerIds) {
      if (layerId.contains(String.valueOf(primaryRouteIndex))
        || layerId.contains(WAYPOINT_LAYER_ID)) {
        continue;
      }
      Layer layer = mapboxMap.getLayer(layerId);
      if (layer != null) {
        layer.setProperties(
          visibility(visible ? VISIBLE : NONE)
        );
      }
    }
  }

  /**
   * Takes the directions route list and draws each line on the map.
   */
  private void drawRoutes() {
    // Add all the sources, the list is traversed backwards to ensure the primary route always gets
    // drawn on top of the others since it initially has a index of zero.
    for (int i = featureCollections.size() - 1; i >= 0; i--) {
      MapUtils.updateMapSourceFromFeatureCollection(
        mapboxMap, featureCollections.get(i),
        featureCollections.get(i).features().get(0).getStringProperty(SOURCE_KEY)
      );

      // Get some required information for the next step
      String sourceId = featureCollections.get(i).features()
        .get(0).getStringProperty(SOURCE_KEY);
      int index = featureCollections.indexOf(featureCollections.get(i));

      // Add the layer IDs to a list so we can quickly remove them when needed without traversing
      // through all the map layers.
      layerIds.add(String.format(Locale.US, ID_FORMAT, GENERIC_ROUTE_SHIELD_LAYER_ID, index));
      layerIds.add(String.format(Locale.US, ID_FORMAT, GENERIC_ROUTE_LAYER_ID, index));

      // Add the route shield first followed by the route to ensure the shield is always on the
      // bottom.
      addRouteShieldLayer(layerIds.get(layerIds.size() - 2), sourceId, index);
      addRouteLayer(layerIds.get(layerIds.size() - 1), sourceId, index);
    }
  }

  private void clearRoutes() {
    removeLayerIds();
    updateArrowLayersVisibilityTo(false);
    clearRouteListData();
  }

  private void generateFeatureCollectionList(List<DirectionsRoute> directionsRoutes) {
    // Each route contains traffic information and should be recreated considering this traffic
    // information.
    for (int i = 0; i < directionsRoutes.size(); i++) {
      featureCollections.add(addTrafficToSource(directionsRoutes.get(i), i));
    }

    // Add the waypoint geometries to represent them as an icon
    featureCollections.add(
      waypointFeatureCollection(directionsRoutes.get(primaryRouteIndex))
    );
  }

  /**
   * The routes also display an icon for each waypoint in the route, we use symbol layers for this.
   */
  private FeatureCollection waypointFeatureCollection(DirectionsRoute route) {
    final List<Feature> waypointFeatures = new ArrayList<>();
    for (RouteLeg leg : route.legs()) {
      waypointFeatures.add(getPointFromLineString(leg, 0));
      waypointFeatures.add(getPointFromLineString(leg, leg.steps().size() - 1));
    }
    return FeatureCollection.fromFeatures(waypointFeatures);
  }

  private void addDirectionWaypoints() {
    MapUtils.updateMapSourceFromFeatureCollection(
      mapboxMap, featureCollections.get(featureCollections.size() - 1), WAYPOINT_SOURCE_ID);
    drawWaypointMarkers(mapboxMap,
      AppCompatResources.getDrawable(mapView.getContext(), originWaypointIcon),
      AppCompatResources.getDrawable(mapView.getContext(), destinationWaypointIcon)
    );
  }

  private void updateArrowLayersVisibilityTo(boolean visible) {
    for (Layer layer : arrowLayers) {
      String targetVisibility = visible ? VISIBLE : NONE;
      if (!targetVisibility.equals(layer.getVisibility().getValue())) {
        layer.setProperties(visibility(targetVisibility));
      }
    }
  }

  private List<Point> obtainArrowPointsFrom(RouteProgress routeProgress) {
    List<Point> reversedCurrent = new ArrayList<>(routeProgress.currentStepPoints());
    Collections.reverse(reversedCurrent);

    LineString arrowLineCurrent = LineString.fromLngLats(reversedCurrent);
    LineString arrowLineUpcoming = LineString.fromLngLats(routeProgress.upcomingStepPoints());

    LineString arrowCurrentSliced = TurfMisc.lineSliceAlong(arrowLineCurrent, 0, THIRTY, TurfConstants.UNIT_METERS);
    LineString arrowUpcomingSliced = TurfMisc.lineSliceAlong(arrowLineUpcoming, 0, THIRTY, TurfConstants.UNIT_METERS);

    Collections.reverse(arrowCurrentSliced.coordinates());

    List<Point> combined = new ArrayList<>();
    combined.addAll(arrowCurrentSliced.coordinates());
    combined.addAll(arrowUpcomingSliced.coordinates());
    return combined;
  }

  private void updateArrowShaftWith(List<Point> points) {
    LineString shaft = LineString.fromLngLats(points);
    arrowShaftGeoJsonFeature = Feature.fromGeometry(shaft);
    arrowShaftGeoJsonSource.setGeoJson(arrowShaftGeoJsonFeature);
  }

  private void updateArrowHeadWith(List<Point> points) {
    double azimuth = TurfMeasurement.bearing(points.get(points.size() - 2), points.get(points.size() - 1));
    arrowHeadGeoJsonFeature = Feature.fromGeometry(points.get(points.size() - 1));
    arrowHeadGeoJsonFeature.addNumberProperty(ARROW_BEARING, (float) MathUtils.wrap(azimuth, 0, MAX_DEGREES));
    arrowHeadGeoJsonSource.setGeoJson(arrowHeadGeoJsonFeature);
  }

  private void initializeUpcomingManeuverArrow() {
    arrowShaftGeoJsonSource = (GeoJsonSource) mapboxMap.getSource(ARROW_SHAFT_SOURCE_ID);
    arrowHeadGeoJsonSource = (GeoJsonSource) mapboxMap.getSource(ARROW_HEAD_SOURCE_ID);

    LineLayer shaftLayer = createArrowShaftLayer();
    LineLayer shaftCasingLayer = createArrowShaftCasingLayer();
    SymbolLayer headLayer = createArrowHeadLayer();
    SymbolLayer headCasingLayer = createArrowHeadCasingLayer();

    if (arrowShaftGeoJsonSource == null && arrowHeadGeoJsonSource == null) {
      initializeArrowShaft();
      initializeArrowHead();

      addArrowHeadIcon();
      addArrowHeadIconCasing();

      mapboxMap.addLayerBelow(shaftCasingLayer, LAYER_ABOVE_UPCOMING_MANEUVER_ARROW);
      mapboxMap.addLayerAbove(headCasingLayer, shaftCasingLayer.getId());

      mapboxMap.addLayerAbove(shaftLayer, headCasingLayer.getId());
      mapboxMap.addLayerAbove(headLayer, shaftLayer.getId());
    }
    initializeArrowLayers(shaftLayer, shaftCasingLayer, headLayer, headCasingLayer);
  }

  private void initializeArrowShaft() {
    arrowShaftGeoJsonSource = new GeoJsonSource(
      ARROW_SHAFT_SOURCE_ID,
      arrowShaftGeoJsonFeature,
      new GeoJsonOptions().withMaxZoom(16)
    );
    mapboxMap.addSource(arrowShaftGeoJsonSource);
  }

  private void initializeArrowHead() {
    arrowHeadGeoJsonSource = new GeoJsonSource(
      ARROW_HEAD_SOURCE_ID,
      arrowShaftGeoJsonFeature,
      new GeoJsonOptions().withMaxZoom(16)
    );
    mapboxMap.addSource(arrowHeadGeoJsonSource);
  }

  private void addArrowHeadIcon() {
    Drawable head = DrawableCompat.wrap(AppCompatResources.getDrawable(mapView.getContext(), R.drawable.ic_arrow_head));
    DrawableCompat.setTint(head.mutate(), arrowColor);
    Bitmap icon = MapImageUtils.getBitmapFromDrawable(head);
    mapboxMap.addImage(ARROW_HEAD_ICON, icon);
  }

  private void addArrowHeadIconCasing() {
    Drawable headCasing = DrawableCompat.wrap(AppCompatResources.getDrawable(mapView.getContext(),
      R.drawable.ic_arrow_head_casing));
    DrawableCompat.setTint(headCasing.mutate(), arrowBorderColor);
    Bitmap icon = MapImageUtils.getBitmapFromDrawable(headCasing);
    mapboxMap.addImage(ARROW_HEAD_ICON_CASING, icon);
  }

  private LineLayer createArrowShaftLayer() {
    LineLayer shaftLayer = (LineLayer) mapboxMap.getLayer(ARROW_SHAFT_LINE_LAYER_ID);
    if (shaftLayer != null) {
      return shaftLayer;
    }
    return new LineLayer(ARROW_SHAFT_LINE_LAYER_ID, ARROW_SHAFT_SOURCE_ID).withProperties(
      PropertyFactory.lineColor(color(arrowColor)),
      PropertyFactory.lineWidth(
        interpolate(linear(), zoom(),
          stop(MIN_ARROW_ZOOM, MIN_ZOOM_ARROW_SHAFT_SCALE),
          stop(MAX_ARROW_ZOOM, MAX_ZOOM_ARROW_SHAFT_SCALE)
        )
      ),
      PropertyFactory.lineCap(Property.LINE_CAP_ROUND),
      PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND),
      PropertyFactory.visibility(NONE),
      PropertyFactory.lineOpacity(
        step(zoom(), OPAQUE,
          stop(
            ARROW_HIDDEN_ZOOM_LEVEL, TRANSPARENT
          )
        )
      )
    );
  }

  private LineLayer createArrowShaftCasingLayer() {
    LineLayer shaftCasingLayer = (LineLayer) mapboxMap.getLayer(ARROW_SHAFT_CASING_LINE_LAYER_ID);
    if (shaftCasingLayer != null) {
      return shaftCasingLayer;
    }
    return new LineLayer(ARROW_SHAFT_CASING_LINE_LAYER_ID, ARROW_SHAFT_SOURCE_ID).withProperties(
      PropertyFactory.lineColor(color(arrowBorderColor)),
      PropertyFactory.lineWidth(
        interpolate(linear(), zoom(),
          stop(MIN_ARROW_ZOOM, MIN_ZOOM_ARROW_SHAFT_CASING_SCALE),
          stop(MAX_ARROW_ZOOM, MAX_ZOOM_ARROW_SHAFT_CASING_SCALE)
        )
      ),
      PropertyFactory.lineCap(Property.LINE_CAP_ROUND),
      PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND),
      PropertyFactory.visibility(NONE),
      PropertyFactory.lineOpacity(
        step(zoom(), OPAQUE,
          stop(
            ARROW_HIDDEN_ZOOM_LEVEL, TRANSPARENT
          )
        )
      )
    );
  }

  private SymbolLayer createArrowHeadLayer() {
    SymbolLayer headLayer = (SymbolLayer) mapboxMap.getLayer(ARROW_HEAD_LAYER_ID);
    if (headLayer != null) {
      return headLayer;
    }
    return new SymbolLayer(ARROW_HEAD_LAYER_ID, ARROW_HEAD_SOURCE_ID)
      .withProperties(
        PropertyFactory.iconImage(ARROW_HEAD_ICON),
        iconAllowOverlap(true),
        iconIgnorePlacement(true),
        PropertyFactory.iconSize(interpolate(linear(), zoom(),
          stop(MIN_ARROW_ZOOM, MIN_ZOOM_ARROW_HEAD_SCALE),
          stop(MAX_ARROW_ZOOM, MAX_ZOOM_ARROW_HEAD_SCALE)
          )
        ),
        PropertyFactory.iconOffset(ARROW_HEAD_OFFSET),
        PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP),
        PropertyFactory.iconRotate(get(ARROW_BEARING)),
        PropertyFactory.visibility(NONE),
        PropertyFactory.iconOpacity(
          step(zoom(), OPAQUE,
            stop(
              ARROW_HIDDEN_ZOOM_LEVEL, TRANSPARENT
            )
          )
        )
      );
  }

  private SymbolLayer createArrowHeadCasingLayer() {
    SymbolLayer headCasingLayer = (SymbolLayer) mapboxMap.getLayer(ARROW_HEAD_CASING_LAYER_ID);
    if (headCasingLayer != null) {
      return headCasingLayer;
    }
    return new SymbolLayer(ARROW_HEAD_CASING_LAYER_ID, ARROW_HEAD_SOURCE_ID).withProperties(
      PropertyFactory.iconImage(ARROW_HEAD_ICON_CASING),
      iconAllowOverlap(true),
      iconIgnorePlacement(true),
      PropertyFactory.iconSize(interpolate(
        linear(), zoom(),
        stop(MIN_ARROW_ZOOM, MIN_ZOOM_ARROW_HEAD_CASING_SCALE),
        stop(MAX_ARROW_ZOOM, MAX_ZOOM_ARROW_HEAD_CASING_SCALE)
      )),
      PropertyFactory.iconOffset(ARROW_HEAD_CASING_OFFSET),
      PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP),
      PropertyFactory.iconRotate(get(ARROW_BEARING)),
      PropertyFactory.visibility(NONE),
      PropertyFactory.iconOpacity(
        step(zoom(), OPAQUE,
          stop(
            ARROW_HIDDEN_ZOOM_LEVEL, TRANSPARENT
          )
        )
      )
    );
  }

  private void initializeArrowLayers(LineLayer shaftLayer, LineLayer shaftCasingLayer, SymbolLayer headLayer,
                                     SymbolLayer headCasingLayer) {
    arrowLayers = new ArrayList<>();
    arrowLayers.add(shaftCasingLayer);
    arrowLayers.add(shaftLayer);
    arrowLayers.add(headCasingLayer);
    arrowLayers.add(headLayer);
  }

  /**
   * When the user switches an alternative route to a primary route, this method alters the
   * appearance.
   */
  private void updatePrimaryRoute(String layerId, int index) {
    Layer layer = mapboxMap.getLayer(layerId);
    if (layer != null) {
      layer.setProperties(
        PropertyFactory.lineColor(match(
          Expression.toString(get(CONGESTION_KEY)),
          color(index == primaryRouteIndex ? routeDefaultColor : alternativeRouteDefaultColor),
          stop("moderate", color(index == primaryRouteIndex ? routeModerateColor : alternativeRouteModerateColor)),
          stop("heavy", color(index == primaryRouteIndex ? routeSevereColor : alternativeRouteSevereColor)),
          stop("severe", color(index == primaryRouteIndex ? routeSevereColor : alternativeRouteSevereColor))
          )
        )
      );
      if (index == primaryRouteIndex) {
        mapboxMap.removeLayer(layer);
        mapboxMap.addLayerBelow(layer, WAYPOINT_LAYER_ID);
      }
    }
  }

  private void updatePrimaryShieldRoute(String layerId, int index) {
    Layer layer = mapboxMap.getLayer(layerId);
    if (layer != null) {
      layer.setProperties(
        PropertyFactory.lineColor(index == primaryRouteIndex ? routeShieldColor : alternativeRouteShieldColor)
      );
      if (index == primaryRouteIndex) {
        mapboxMap.removeLayer(layer);
        mapboxMap.addLayerBelow(layer, WAYPOINT_LAYER_ID);
      }
    }
  }

  /**
   * Add the route layer to the map either using the custom style values or the default.
   */
  private void addRouteLayer(String layerId, String sourceId, int index) {
    float scale = index == primaryRouteIndex ? routeScale : alternativeRouteScale;
    Layer routeLayer = new LineLayer(layerId, sourceId).withProperties(
      PropertyFactory.lineCap(Property.LINE_CAP_ROUND),
      PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND),
      PropertyFactory.lineWidth(interpolate(
        exponential(1.5f), zoom(),
        stop(4f, 3f * scale),
        stop(10f, 4f * scale),
        stop(13f, 6f * scale),
        stop(16f, 10f * scale),
        stop(19f, 14f * scale),
        stop(22f, 18f * scale)
        )
      ),
      PropertyFactory.lineColor(match(
        Expression.toString(get(CONGESTION_KEY)),
        color(index == primaryRouteIndex ? routeDefaultColor : alternativeRouteDefaultColor),
        stop("moderate", color(index == primaryRouteIndex ? routeModerateColor : alternativeRouteModerateColor)),
        stop("heavy", color(index == primaryRouteIndex ? routeSevereColor : alternativeRouteSevereColor)),
        stop("severe", color(index == primaryRouteIndex ? routeSevereColor : alternativeRouteSevereColor))
        )
      )
    );
    MapUtils.addLayerToMap(mapboxMap, routeLayer, belowLayer);
  }

  private void removeLayerIds() {
    if (!layerIds.isEmpty()) {
      for (String id : layerIds) {
        mapboxMap.removeLayer(id);
      }
    }
  }

  private void clearRouteListData() {
    if (!directionsRoutes.isEmpty()) {
      directionsRoutes.clear();
    }
    if (!routeLineStrings.isEmpty()) {
      routeLineStrings.clear();
    }
    if (!featureCollections.isEmpty()) {
      featureCollections.clear();
    }
  }

  /**
   * Add the route shield layer to the map either using the custom style values or the default.
   */
  private void addRouteShieldLayer(String layerId, String sourceId, int index) {
    float scale = index == primaryRouteIndex ? routeScale : alternativeRouteScale;
    Layer routeLayer = new LineLayer(layerId, sourceId).withProperties(
      PropertyFactory.lineCap(Property.LINE_CAP_ROUND),
      PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND),
      PropertyFactory.lineWidth(interpolate(
        exponential(1.5f), zoom(),
        stop(10f, 7f),
        stop(14f, 10.5f * scale),
        stop(16.5f, 15.5f * scale),
        stop(19f, 24f * scale),
        stop(22f, 29f * scale)
        )
      ),
      PropertyFactory.lineColor(
        index == primaryRouteIndex ? routeShieldColor : alternativeRouteShieldColor)
    );
    MapUtils.addLayerToMap(mapboxMap, routeLayer, belowLayer);
  }

  /**
   * Loads in all the custom values the user might have set such as colors and line width scalars.
   * Anything they didn't set, results in using the default values.
   */
  private void getAttributes() {
    Context context = mapView.getContext();
    TypedArray typedArray = context.obtainStyledAttributes(styleRes, R.styleable.NavigationMapRoute);

    // Primary Route attributes
    routeDefaultColor = typedArray.getColor(R.styleable.NavigationMapRoute_routeColor,
      ContextCompat.getColor(context, R.color.mapbox_navigation_route_layer_blue));
    routeModerateColor = typedArray.getColor(
      R.styleable.NavigationMapRoute_routeModerateCongestionColor,
      ContextCompat.getColor(context, R.color.mapbox_navigation_route_layer_congestion_yellow));
    routeSevereColor = typedArray.getColor(
      R.styleable.NavigationMapRoute_routeSevereCongestionColor,
      ContextCompat.getColor(context, R.color.mapbox_navigation_route_layer_congestion_red));
    routeShieldColor = typedArray.getColor(R.styleable.NavigationMapRoute_routeShieldColor,
      ContextCompat.getColor(context, R.color.mapbox_navigation_route_shield_layer_color));
    routeScale = typedArray.getFloat(R.styleable.NavigationMapRoute_routeScale, 1.0f);

    // Secondary Routes attributes
    alternativeRouteDefaultColor = typedArray.getColor(
      R.styleable.NavigationMapRoute_alternativeRouteColor,
      ContextCompat.getColor(context, R.color.mapbox_navigation_route_alternative_color));
    alternativeRouteModerateColor = typedArray.getColor(
      R.styleable.NavigationMapRoute_alternativeRouteModerateCongestionColor,
      ContextCompat.getColor(context, R.color.mapbox_navigation_route_alternative_congestion_yellow));
    alternativeRouteSevereColor = typedArray.getColor(
      R.styleable.NavigationMapRoute_alternativeRouteSevereCongestionColor,
      ContextCompat.getColor(context, R.color.mapbox_navigation_route_alternative_congestion_red));
    alternativeRouteShieldColor = typedArray.getColor(
      R.styleable.NavigationMapRoute_alternativeRouteShieldColor,
      ContextCompat.getColor(context, R.color.mapbox_navigation_route_alternative_shield_color));
    alternativeRouteScale = typedArray.getFloat(
      R.styleable.NavigationMapRoute_alternativeRouteScale, 1.0f);

    // Waypoint attributes
    originWaypointIcon = typedArray.getResourceId(
      R.styleable.NavigationMapRoute_originWaypointIcon, R.drawable.ic_route_origin);
    destinationWaypointIcon = typedArray.getResourceId(
      R.styleable.NavigationMapRoute_destinationWaypointIcon, R.drawable.ic_route_destination);

    arrowColor = typedArray.getColor(R.styleable.NavigationMapRoute_upcomingManeuverArrowColor,
      ContextCompat.getColor(context, R.color.mapbox_navigation_route_upcoming_maneuver_arrow_color));
    arrowBorderColor = typedArray.getColor(R.styleable.NavigationMapRoute_upcomingManeuverArrowBorderColor,
      ContextCompat.getColor(context, R.color.mapbox_navigation_route_upcoming_maneuver_arrow_border_color));

    typedArray.recycle();
  }

  /**
   * Iterate through map style layers backwards till the first not-symbol layer is found.
   */
  private void placeRouteBelow() {
    if (belowLayer == null || belowLayer.isEmpty()) {
      List<Layer> styleLayers = mapboxMap.getLayers();
      if (styleLayers == null) {
        return;
      }
      for (int i = 0; i < styleLayers.size(); i++) {
        if (!(styleLayers.get(i) instanceof SymbolLayer)
          // Avoid placing the route on top of the user location layer
          && !styleLayers.get(i).getId().contains("mapbox-location")) {
          belowLayer = styleLayers.get(i).getId();
        }
      }
    }
  }

  private void drawWaypointMarkers(@NonNull MapboxMap mapboxMap, @Nullable Drawable originMarker,
                                   @Nullable Drawable destinationMarker) {
    if (originMarker == null || destinationMarker == null) {
      return;
    }

    SymbolLayer waypointLayer = mapboxMap.getLayerAs(WAYPOINT_LAYER_ID);
    if (waypointLayer == null) {
      Bitmap bitmap = MapImageUtils.getBitmapFromDrawable(originMarker);
      mapboxMap.addImage("originMarker", bitmap);
      bitmap = MapImageUtils.getBitmapFromDrawable(destinationMarker);
      mapboxMap.addImage("destinationMarker", bitmap);

      waypointLayer = new SymbolLayer(WAYPOINT_LAYER_ID, WAYPOINT_SOURCE_ID).withProperties(
        PropertyFactory.iconImage(match(
          Expression.toString(get("waypoint")), literal("originMarker"),
          stop("origin", literal("originMarker")),
          stop("destination", literal("destinationMarker"))
          )
        ),
        PropertyFactory.iconSize(interpolate(
          exponential(1.5f), zoom(),
          stop(22f, 2.8f),
          stop(12f, 1.3f),
          stop(10f, 0.8f),
          stop(0f, 0.6f)
        )),
        PropertyFactory.iconPitchAlignment(Property.ANCHOR_MAP),
        PropertyFactory.iconAllowOverlap(true),
        PropertyFactory.iconIgnorePlacement(true)
      );
      layerIds.add(WAYPOINT_LAYER_ID);
      MapUtils.addLayerToMap(mapboxMap, waypointLayer, belowLayer);
    }
  }

  private Feature getPointFromLineString(RouteLeg leg, int index) {
    Feature feature = Feature.fromGeometry(Point.fromLngLat(
      leg.steps().get(index).maneuver().location().longitude(),
      leg.steps().get(index).maneuver().location().latitude()
    ));
    feature.addStringProperty(SOURCE_KEY, WAYPOINT_SOURCE_ID);
    feature.addStringProperty("waypoint",
      index == 0 ? "origin" : "destination"
    );
    return feature;
  }

  private void initialize() {
    alternativesVisible = true;
    getAttributes();
    placeRouteBelow();
    initializeUpcomingManeuverArrow();
  }

  private void addListeners() {
    mapboxMap.addOnMapClickListener(this);
    if (navigation != null) {
      navigation.addProgressChangeListener(progressChangeListener);
    }
    mapView.addOnMapChangedListener(this);
  }

  /**
   * Remove the route line from the map style.
   *
   * @since 0.4.0
   */
  public void removeRoute() {
    clearRoutes();
  }

  @Override
  public void onMapClick(@NonNull LatLng point) {
    if (invalidMapClick()) {
      return;
    }
    final int currentRouteIndex = primaryRouteIndex;

    if (findClickedRoute(point)) {
      return;
    }
    checkNewRouteFound(currentRouteIndex);
  }

  private boolean invalidMapClick() {
    return routeLineStrings == null || routeLineStrings.isEmpty() || !alternativesVisible;
  }

  private boolean findClickedRoute(@NonNull LatLng point) {
    HashMap<Double, DirectionsRoute> routeDistancesAwayFromClick = new HashMap<>();

    Point clickPoint = Point.fromLngLat(point.getLongitude(), point.getLatitude());

    if (calculateClickDistancesFromRoutes(routeDistancesAwayFromClick, clickPoint)) {
      return true;
    }
    List<Double> distancesAwayFromClick = new ArrayList<>(routeDistancesAwayFromClick.keySet());
    Collections.sort(distancesAwayFromClick);

    DirectionsRoute clickedRoute = routeDistancesAwayFromClick.get(distancesAwayFromClick.get(0));
    primaryRouteIndex = directionsRoutes.indexOf(clickedRoute);
    return false;
  }

  private boolean calculateClickDistancesFromRoutes(HashMap<Double, DirectionsRoute> routeDistancesAwayFromClick,
                                                    Point clickPoint) {
    for (LineString lineString : routeLineStrings.keySet()) {
      Point pointOnLine = findPointOnLine(clickPoint, lineString);

      if (pointOnLine == null) {
        return true;
      }
      double distance = TurfMeasurement.distance(clickPoint, pointOnLine, TurfConstants.UNIT_METERS);
      routeDistancesAwayFromClick.put(distance, routeLineStrings.get(lineString));
    }
    return false;
  }

  private Point findPointOnLine(Point clickPoint, LineString lineString) {
    List<Point> linePoints = lineString.coordinates();
    Feature feature = TurfMisc.nearestPointOnLine(clickPoint, linePoints);
    return (Point) feature.geometry();
  }

  private void checkNewRouteFound(int currentRouteIndex) {
    if (currentRouteIndex != primaryRouteIndex) {
      updateRoute();
      boolean isValidPrimaryIndex = primaryRouteIndex >= 0 && primaryRouteIndex < directionsRoutes.size();
      if (isValidPrimaryIndex && onRouteSelectionChangeListener != null) {
        DirectionsRoute selectedRoute = directionsRoutes.get(primaryRouteIndex);
        onRouteSelectionChangeListener.onNewPrimaryRouteSelected(selectedRoute);
      }
    }
  }

  private void updateRoute() {
    // Update all route geometries to reflect their appropriate colors depending on if they are
    // alternative or primary.
    for (FeatureCollection featureCollection : featureCollections) {
      if (!(featureCollection.features().get(0).geometry() instanceof Point)) {
        int index = featureCollection.features().get(0).getNumberProperty(INDEX_KEY).intValue();
        updatePrimaryShieldRoute(String.format(Locale.US, ID_FORMAT, GENERIC_ROUTE_SHIELD_LAYER_ID,
          index), index);
        updatePrimaryRoute(String.format(Locale.US, ID_FORMAT, GENERIC_ROUTE_LAYER_ID,
          index), index);
      }
    }
  }

  /**
   * Called when a map change events occurs. Used specifically to detect loading of a new style, if
   * applicable reapply the route line source and layers.
   *
   * @param change the map change event that occurred
   * @since 0.4.0
   */
  @Override
  public void onMapChanged(int change) {
    if (change == MapView.DID_FINISH_LOADING_STYLE) {
      placeRouteBelow();
      initializeUpcomingManeuverArrow();
      drawRoutes();
      addDirectionWaypoints();
      showAlternativeRoutes(alternativesVisible);
    }
  }

  /**
   * This method should be called only if you have passed {@link MapboxNavigation}
   * into the constructor.
   * <p>
   * This method will add the {@link ProgressChangeListener} that was originally added so updates
   * to the {@link MapboxMap} continue.
   *
   * @since 0.15.0
   */
  @OnLifecycleEvent(Lifecycle.Event.ON_START)
  public void onStart() {
    if (navigation != null) {
      navigation.addProgressChangeListener(progressChangeListener);
    }
  }

  /**
   * This method should be called only if you have passed {@link MapboxNavigation}
   * into the constructor.
   * <p>
   * This method will remove the {@link ProgressChangeListener} that was originally added so updates
   * to the {@link MapboxMap} discontinue.
   *
   * @since 0.15.0
   */
  @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
  public void onStop() {
    if (navigation != null) {
      navigation.removeProgressChangeListener(progressChangeListener);
    }
  }

  /**
   * If the {@link DirectionsRoute} request contains congestion information via annotations, breakup
   * the source into pieces so data-driven styling can be used to change the route colors
   * accordingly.
   */
  private FeatureCollection addTrafficToSource(DirectionsRoute route, int index) {
    final List<Feature> features = new ArrayList<>();
    LineString originalGeometry = LineString.fromPolyline(route.geometry(), Constants.PRECISION_6);
    buildRouteFeatureFromGeometry(index, features, originalGeometry);
    routeLineStrings.put(originalGeometry, route);

    LineString lineString = LineString.fromPolyline(route.geometry(), Constants.PRECISION_6);
    buildTrafficFeaturesFromRoute(route, index, features, lineString);
    return FeatureCollection.fromFeatures(features);
  }

  private void buildRouteFeatureFromGeometry(int index, List<Feature> features, LineString originalGeometry) {
    Feature feat = Feature.fromGeometry(originalGeometry);
    feat.addStringProperty(SOURCE_KEY, String.format(Locale.US, ID_FORMAT, GENERIC_ROUTE_SOURCE_ID, index));
    feat.addNumberProperty(INDEX_KEY, index);
    features.add(feat);
  }

  private void buildTrafficFeaturesFromRoute(DirectionsRoute route, int index,
                                             List<Feature> features, LineString lineString) {
    for (RouteLeg leg : route.legs()) {
      if (leg.annotation() != null && leg.annotation().congestion() != null) {
        for (int i = 0; i < leg.annotation().congestion().size(); i++) {
          // See https://github.com/mapbox/mapbox-navigation-android/issues/353
          if (leg.annotation().congestion().size() + 1 <= lineString.coordinates().size()) {

            List<Point> points = new ArrayList<>();
            points.add(lineString.coordinates().get(i));
            points.add(lineString.coordinates().get(i + 1));

            LineString congestionLineString = LineString.fromLngLats(points);
            Feature feature = Feature.fromGeometry(congestionLineString);
            feature.addStringProperty(CONGESTION_KEY, leg.annotation().congestion().get(i));
            feature.addStringProperty(SOURCE_KEY, String.format(Locale.US, ID_FORMAT,
              GENERIC_ROUTE_SOURCE_ID, index));
            feature.addNumberProperty(INDEX_KEY, index);
            features.add(feature);
          }
        }
      } else {
        Feature feature = Feature.fromGeometry(lineString);
        features.add(feature);
      }
    }
  }
}