package org.thoughtcrime.securesms.map;

import android.content.Context;
import android.graphics.Bitmap;
import androidx.annotation.NonNull;
import android.util.Log;

import com.b44t.messenger.DcEventCenter;
import com.mapbox.android.core.permissions.PermissionsManager;
import com.mapbox.geojson.Feature;
import com.mapbox.geojson.FeatureCollection;
import com.mapbox.mapboxsdk.exceptions.InvalidLatLngBoundsException;
import com.mapbox.mapboxsdk.geometry.LatLngBounds;
import com.mapbox.mapboxsdk.location.LocationComponent;
import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions;
import com.mapbox.mapboxsdk.maps.Style;
import com.mapbox.mapboxsdk.style.expressions.Expression;
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.GeoJsonSource;

import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.connect.ApplicationDcContext;
import org.thoughtcrime.securesms.connect.DcHelper;
import org.thoughtcrime.securesms.map.DataCollectionTask.DataCollectionCallback;
import org.thoughtcrime.securesms.map.GenerateInfoWindowTask.GenerateInfoWindowCallback;
import org.thoughtcrime.securesms.map.model.FilterProvider;
import org.thoughtcrime.securesms.map.model.MapSource;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import static com.b44t.messenger.DcContext.DC_EVENT_LOCATION_CHANGED;
import static com.b44t.messenger.DcContext.DC_GCL_ADD_SELF;
import static com.mapbox.mapboxsdk.location.modes.RenderMode.COMPASS;
import static com.mapbox.mapboxsdk.style.expressions.Expression.all;
import static com.mapbox.mapboxsdk.style.expressions.Expression.get;
import static com.mapbox.mapboxsdk.style.expressions.Expression.has;
import static com.mapbox.mapboxsdk.style.expressions.Expression.length;
import static com.mapbox.mapboxsdk.style.expressions.Expression.literal;
import static com.mapbox.mapboxsdk.style.expressions.Expression.eq;
import static com.mapbox.mapboxsdk.style.expressions.Expression.neq;
import static com.mapbox.mapboxsdk.style.expressions.Expression.not;
import static com.mapbox.mapboxsdk.style.expressions.Expression.switchCase;
import static com.mapbox.mapboxsdk.style.expressions.Expression.toBool;
import static com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM_LEFT;
import static com.mapbox.mapboxsdk.style.layers.Property.NONE;
import static com.mapbox.mapboxsdk.style.layers.Property.TEXT_ANCHOR_CENTER;
import static com.mapbox.mapboxsdk.style.layers.Property.TEXT_ANCHOR_TOP;
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.iconAnchor;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconIgnorePlacement;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconImage;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconOffset;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconSize;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.lineColor;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.lineJoin;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.lineOpacity;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.lineWidth;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.textAllowOverlap;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.textAnchor;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.textColor;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.textField;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.textIgnorePlacement;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.textOffset;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.textSize;
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.visibility;
import static org.thoughtcrime.securesms.map.model.MapSource.INFO_WINDOW_LAYER;
import static org.thoughtcrime.securesms.map.model.MapSource.LINE_FEATURE_LIST;
import static org.thoughtcrime.securesms.util.BitmapUtil.generateColoredBitmap;


/**
 * Created by cyberta on 07.03.19.
 */

public class MapDataManager implements DcEventCenter.DcEventDelegate, GenerateInfoWindowCallback, DataCollectionCallback {
    public static final String MARKER_SELECTED = "MARKER_SELECTED";
    public static final String LAST_LOCATION = "LAST_LOCATION";
    public static final String CONTACT_ID = "CONTACT_ID";
    public static final String INFO_WINDOW_ID = "INFO_WINDOW_ID";
    public static final String TIMESTAMP = "TIMESTAMP";
    public static final String MESSAGE_ID = "MESSAGE_ID";
    public static final String ACCURACY = "ACCURACY";
    public static final String MARKER_CHAR = "MARKER_CHAR";
    public static final String POI_LONG_DESCRIPTION = "POI_LONG_DESCRIPTION";
    public static final String MARKER_ICON = "MARKER_ICON";
    public static final String IS_POI = "IS_POI";
    public static final String LAST_POSITION_ICON = "LAST_POSITION_ICON";
    public static final String LAST_POSITION_LABEL = "LAST_POSITION_LABEL";
    private static final String INFO_WINDOW_SRC = "INFO_WINDOW_SRC";
    private static final String LAST_POSITION_LAYER = "LAST_POSITION_LAYER";
    private static final String LAST_POSITION_SOURCE = "LAST_POSITION_SRC";

    public static final int ALL_CHATS_GLOBAL_MAP = 0;
    public static final long TIMESTAMP_NOW = 0L;
    public static final long TIME_FRAME = 1000 * 60 * 60 * 24 * 2; // 2d
    private static final long DEFAULT_LAST_POSITION_DELTA = 1000 * 60 * 60 * 24; // 1d

    private static final String TAG = MapDataManager.class.getSimpleName();
    private Style mapboxStyle;
    private ConcurrentHashMap<Integer, MapSource> contactMapSources = new ConcurrentHashMap<>();
    private ConcurrentHashMap<String, LinkedList<Feature>> featureCollections = new ConcurrentHashMap<>();
    private ConcurrentHashMap<Integer, Feature> lastPositions = new ConcurrentHashMap<>();
    private FilterProvider filterProvider = new FilterProvider();
    private Feature selectedFeature;
    private int chatId;
    private LatLngBounds.Builder boundingBuilder;
    private Context context;
    private ApplicationDcContext dcContext;
    private MapDataState callback;
    private boolean isInitial = true;
    private boolean showTraces = false;
    private LocationComponent locationComponent;

    public interface MapDataState {
        void onDataInitialized(LatLngBounds bounds);
    }

    public MapDataManager(Context context, @NonNull Style mapboxMapStyle, LocationComponent locationComponent, int chatId, MapDataState updateCallback) {
        Log.d(TAG, "performance test - create map manager");
        this.mapboxStyle = mapboxMapStyle;
        this.context = context;
        this.dcContext = DcHelper.getContext(context);
        this.chatId = chatId;
        boundingBuilder = new LatLngBounds.Builder();
        this.callback = updateCallback;
        this.locationComponent = locationComponent;

        initInfoWindowLayer();
        initLastPositionLayer();
        initLocationComponent();

        filterProvider.setMessageFilter(true);
        filterProvider.setLastPositionFilter(System.currentTimeMillis() - DEFAULT_LAST_POSITION_DELTA);
        applyLastPositionFilter();

        updateSources();
        dcContext.eventCenter.addObserver(DC_EVENT_LOCATION_CHANGED, this);

        Log.d(TAG, "performance test - create map manager finished");
    }

    public void onResume() {
        dcContext.eventCenter.addObserver(DC_EVENT_LOCATION_CHANGED, this);
        if (!isInitial) {
            updateSources();
        }
        isInitial = false;
    }

    public void onPause() {
        dcContext.eventCenter.removeObserver(DC_EVENT_LOCATION_CHANGED, this);
    }

    public void onDestroy() {
        GenerateInfoWindowTask.cancelRunningTasks();
        DataCollectionTask.cancelRunningTasks();
        Log.d(TAG, "performance test - Map manager destroyed");
    }

    @Override
    public Context getContext() {
        return context;
    }

    public void refreshSource(int contactId) {
        MapSource source = contactMapSources.get(contactId);
        LinkedList<Feature> collection = featureCollections.get(source.getMarkerFeatureCollection());
        GeoJsonSource pointSource = (GeoJsonSource) mapboxStyle.getSource(source.getMarkerSource());
        pointSource.setGeoJson(FeatureCollection.fromFeatures(collection));
        LinkedList<Feature> lineFeatures = featureCollections.get(source.getLineFeatureCollection());
        GeoJsonSource lineSource = (GeoJsonSource) mapboxStyle.getSource(source.getLineSource());
        lineSource.setGeoJson(FeatureCollection.fromFeatures(lineFeatures));
        GeoJsonSource lastPostionSource = (GeoJsonSource) mapboxStyle.getSource(LAST_POSITION_SOURCE);
        lastPostionSource.setGeoJson(FeatureCollection.fromFeatures(new LinkedList<>(lastPositions.values())));
    }

    @Override
    public void handleEvent(int eventId, Object data1, Object data2) {
        Log.d(TAG, "updateEvent in MapDataManager called. eventId: " + eventId);
        int contactId = ((Long) data1).intValue();
        if (contactMapSources.containsKey(contactId)) {
            DataCollector collector = new DataCollector(dcContext,
                    contactMapSources,
                    featureCollections,
                    lastPositions, null);
            collector.updateSource(chatId,
                    contactId,
                    System.currentTimeMillis() - TIME_FRAME,
                    TIMESTAMP_NOW);

            refreshSource(contactId);
        }
        Log.d(TAG, "updateEvent in MapDataManager called. finished: " + eventId);
    }

    @Override
    public boolean runOnMain() {
        return true;
    }


    public String[] getMarkerLayers() {
        String markerLayers[] = new String[contactMapSources.size() + 1];
        int i = 0;
        for (Map.Entry<Integer, MapSource> entry : contactMapSources.entrySet()) {
            markerLayers[i] = entry.getValue().getMarkerLayer();
            i += 1;
        }

        markerLayers[contactMapSources.size()] = LAST_POSITION_LAYER;
        return markerLayers;
    }

    public boolean unselectMarker() {
        if (selectedFeature != null) {
            selectedFeature.addBooleanProperty(MARKER_SELECTED, false);
            refreshSource(selectedFeature.getNumberProperty(CONTACT_ID).intValue());
            selectedFeature = null;
            GeoJsonSource source = (GeoJsonSource) mapboxStyle.getSource(INFO_WINDOW_SRC);
            source.setGeoJson(FeatureCollection.fromFeatures(new ArrayList<>()));
            return true;
        }
        return false;
    }

    public void setMarkerSelected(String featureId) {
        if (selectedFeature == null) {
            setNewMarkerSelected(featureId);
        } else if (selectedFeature.id().equals(featureId)) {
            updateSelectedMarker();
        } else {
            replaceSelectedMarker(featureId);
        }

        new GenerateInfoWindowTask(this).execute(selectedFeature);
    }

    /**
     * Invoked when the bitmaps have been generated from a view.
     */
    @Override
    public void setInfoWindowResults(Bitmap result) {
        mapboxStyle.addImage(INFO_WINDOW_ID, result);
        GeoJsonSource infoWindowSource = (GeoJsonSource) mapboxStyle.getSource(INFO_WINDOW_SRC);
        infoWindowSource.setGeoJson(selectedFeature);
    }

    @Override
    public void onDataCollectionFinished() {
        for (MapSource source : contactMapSources.values()) {
            initContactBasedLayers(source);
            refreshSource(source.getContactId());
            applyMarkerFilter(source);
            applyLineFilter(source);
        }

        if (boundingBuilder != null && callback != null) {
            LatLngBounds bound = null;
            try {
                bound = boundingBuilder.build();
            } catch (InvalidLatLngBoundsException e) {
                Log.w(TAG, e.getLocalizedMessage());
            }
            callback.onDataInitialized(bound);
        }
    }

    public void filterRange(long startTimestamp, long endTimestamp) {
        int[] contactIds = getContactIds(chatId);
        filterProvider.setRangeFilter(startTimestamp, endTimestamp);
        applyFilters(contactIds);
    }

    public void filterLastPositions(long timestamp) {
        int[] contactIds = getContactIds(chatId);
        filterProvider.setLastPositionFilter(timestamp);
        applyFilters(contactIds);
    }

    public void showTraces(boolean show) {
        int[] contactIds = getContactIds(chatId);
        this.showTraces = show;
        filterProvider.setMessageFilter(!show);
        applyFilters(contactIds);
    }

    public int getChatId() {
        return chatId;
    }

    private void showLineLayer(MapSource source) {
        LineLayer lineLayer = (LineLayer) mapboxStyle.getLayer(source.getLineLayer());
        if (lineLayer != null) {
            lineLayer.setProperties(visibility(showTraces ? VISIBLE : NONE));
        }
    }

    private void applyFilters(int[] contactIds) {
        for (int contactId : contactIds) {
            MapSource contactMapMetadata = contactMapSources.get(contactId);
            if (contactMapMetadata == null) {
                continue;
            }
            showLineLayer(contactMapMetadata);
            applyMarkerFilter(contactMapMetadata);
            applyLineFilter(contactMapMetadata);
        }
        applyLastPositionFilter();
    }

    private void applyLastPositionFilter() {
        SymbolLayer markerLayer = (SymbolLayer) mapboxStyle.getLayer(LAST_POSITION_LAYER);
        if (markerLayer != null) {
            markerLayer.setFilter(filterProvider.getTimeFilter());
        }
    }

    private void applyMarkerFilter(MapSource source) {
        SymbolLayer markerLayer = (SymbolLayer) mapboxStyle.getLayer(source.getMarkerLayer());
        if (markerLayer != null) {
            markerLayer.setFilter(filterProvider.getMarkerFilter());
        }
    }

    private void applyLineFilter(MapSource source) {
        LineLayer lineLayer = (LineLayer) mapboxStyle.getLayer(source.getLineLayer());
        if (lineLayer != null) {
            lineLayer.setFilter(filterProvider.getTimeFilter());
        }
    }

    private int[] getContactIds(int chatId) {
        if (chatId == ALL_CHATS_GLOBAL_MAP) {
            return dcContext.getContacts(DC_GCL_ADD_SELF, "");
        } else {
            int[] contactIds = dcContext.getChatContacts(chatId);
            boolean hasSelf = false;
            for (int contact : contactIds) {
                if (contact == 1) {
                    hasSelf = true;
                    break;
                }
            }
            if (!hasSelf) {
                contactIds = Arrays.copyOf(contactIds, contactIds.length + 1);
                contactIds[contactIds.length - 1] = 1;
            }
            return contactIds;
        }
    }

    private void initInfoWindowLayer() {
        Expression iconOffset = switchCase(
                toBool(get(LAST_LOCATION)), literal(new Float[] {-2f, -25f}),
                literal(new Float[] {-2f, -20f}));
        GeoJsonSource infoWindowSource = new GeoJsonSource(INFO_WINDOW_SRC);
        mapboxStyle.addSource(infoWindowSource);
        mapboxStyle.addLayer(new SymbolLayer(INFO_WINDOW_LAYER, INFO_WINDOW_SRC).withProperties(
                iconImage(INFO_WINDOW_ID),
                iconAnchor(ICON_ANCHOR_BOTTOM_LEFT),
                     /* all info window and marker image to appear at the same time*/
                iconAllowOverlap(true),
                    /* offset the info window to be above the marker */
                iconOffset(iconOffset)
        ));
    }

    private void initLastPositionLayer() {
        GeoJsonSource lastPositionSource = new GeoJsonSource(LAST_POSITION_SOURCE);
        mapboxStyle.addSource(lastPositionSource);
        Expression markerSize =
                switchCase(toBool(get(MARKER_SELECTED)), literal(1.75f), literal(1.25f));
        mapboxStyle.addLayerBelow(new SymbolLayer(LAST_POSITION_LAYER, LAST_POSITION_SOURCE).withProperties(
                iconImage(get(LAST_POSITION_ICON)),
                     /* all info window and marker image to appear at the same time*/
                iconAllowOverlap(true),
                iconIgnorePlacement(true),
                iconSize(markerSize),
                textField(get(LAST_POSITION_LABEL)),
                textAnchor(TEXT_ANCHOR_TOP),
                textOffset(new Float[]{0.0f, 1.0f}),
                textAllowOverlap(true),
                textIgnorePlacement(true)
        ).withFilter(filterProvider.getTimeFilter()), INFO_WINDOW_LAYER);
    }

    @SuppressWarnings( {"MissingPermission"})
    private void initLocationComponent() {
        if (! PermissionsManager.areLocationPermissionsGranted(context)) {
            return;
        }

        LocationComponentActivationOptions locationComponentActivationOptions = LocationComponentActivationOptions
                .builder(context, mapboxStyle)
                .build();
        locationComponent.activateLocationComponent(locationComponentActivationOptions);
        locationComponent.setRenderMode(COMPASS);
        locationComponent.setLocationComponentEnabled(true);
    }

    private void initContactBasedLayers(MapSource source) {
        if (mapboxStyle.getLayer(source.getMarkerLayer()) != null) {
            return;
        }

        GeoJsonSource markerPositionSource = new GeoJsonSource(source.getMarkerSource());
        GeoJsonSource linePositionSource = new GeoJsonSource(source.getLineSource());

        try {
            mapboxStyle.addSource(markerPositionSource);
            mapboxStyle.addSource(linePositionSource);
        } catch (RuntimeException e) {
            //TODO: specify exception more
            Log.e(TAG, "Unable to init GeoJsonSources. Already added to mapBoxMap? " + e.getMessage());
        }

        mapboxStyle.addImage(source.getMarkerLastPositon(),
                generateColoredLastPositionIcon(source.getColorArgb()));
        mapboxStyle.addImage(source.getMarkerIcon(),
                generateColoredLocationIcon(source.getColorArgb()));
        mapboxStyle.addImage(source.getMarkerPoi(),
                generateColoredPoiIcon(source.getColorArgb()));

        Expression markerSize =
                switchCase(
                        neq(length(get(MARKER_CHAR)), literal(0)),
                            switchCase(toBool(get(MARKER_SELECTED)), literal(2.25f), literal(2.0f)),
                        neq(get(MESSAGE_ID), literal(0)),
                            switchCase(toBool(get(MARKER_SELECTED)), literal(2.25f), literal(2.0f)),
                        switchCase(toBool(get(MARKER_SELECTED)), literal(1.1f), literal(0.7f)));
        Expression markerIcon = get(MARKER_ICON);

        mapboxStyle.addLayerBelow(new LineLayer(source.getLineLayer(), source.getLineSource())
                .withProperties(PropertyFactory.lineCap(Property.LINE_CAP_ROUND),
                        lineJoin(Property.LINE_JOIN_ROUND),
                        lineWidth(3f),
                        lineOpacity(0.5f),
                        lineColor(source.getColorArgb()),
                        visibility(NONE)
                )
                .withFilter(filterProvider.getTimeFilter()),
                LAST_POSITION_LAYER);


        Expression textField = switchCase(eq(length(get(MARKER_CHAR)), 1), get(MARKER_CHAR),
                get(POI_LONG_DESCRIPTION));
        Float[] offset = new Float[] {0.0f, 1.25f};
        Float[] zeroOffset = new Float[] {0.0f, 0.0f};
        Expression textOffset = switchCase(
                has(POI_LONG_DESCRIPTION), literal(offset),
                literal(zeroOffset));
        Expression textColor = switchCase(
                has(POI_LONG_DESCRIPTION), literal("#000000"),
                literal("#FFFFFF")
        );
        Expression textAnchor = switchCase(
                has(POI_LONG_DESCRIPTION), literal(TEXT_ANCHOR_TOP),
                literal(TEXT_ANCHOR_CENTER)
        );
        Expression textSize = switchCase(
                has(POI_LONG_DESCRIPTION), literal(12.0f),
                literal(15.0f)
        );

        mapboxStyle.addLayerBelow(new SymbolLayer(source.getMarkerLayer(), source.getMarkerSource())
                        .withProperties(
                                iconImage(markerIcon),
                                iconSize(markerSize),
                                iconIgnorePlacement(false),
                                iconAllowOverlap(false),
                                textField(textField),
                                textOffset(textOffset),
                                textAnchor(textAnchor),
                                textSize(textSize),
                                textColor(textColor))
                        .withFilter(all(filterProvider.getMarkerFilter(),
                                not(get(LAST_LOCATION)))),
                LAST_POSITION_LAYER);
    }

    private Bitmap generateColoredLastPositionIcon(int colorFilter) {
        return generateColoredBitmap(context, colorFilter, R.drawable.ic_location_on_white_48dp);
    }

    private Bitmap generateColoredLocationIcon(int colorFilter) {
        return generateColoredBitmap(context, colorFilter, R.drawable.ic_location_dot);
    }

    private Bitmap generateColoredPoiIcon(int colorFilter) {
        return generateColoredBitmap(context, colorFilter, R.drawable.ic_location_poi_dot);
    }

    private void updateSources() {
        new DataCollectionTask(dcContext,
                chatId,
                getContactIds(chatId),
                contactMapSources,
                featureCollections,
                lastPositions,
                boundingBuilder,
                this).execute();
    }

    private void replaceSelectedMarker(String featureId) {
        Feature feature = getFeatureWithId(featureId);
        feature.addBooleanProperty(MARKER_SELECTED, true);
        selectedFeature.addBooleanProperty(MARKER_SELECTED, false);

        int lastContactId = selectedFeature.getNumberProperty(CONTACT_ID).intValue();
        int currentContactId = feature.getNumberProperty(CONTACT_ID).intValue();

        selectedFeature = feature;
        refreshSource(currentContactId);
        if (lastContactId != currentContactId) {
            refreshSource(lastContactId);
        }

    }

    private void updateSelectedMarker() {
        boolean isSelected = selectedFeature.getBooleanProperty(MARKER_SELECTED);
        selectedFeature.addBooleanProperty(MARKER_SELECTED, !isSelected);
        refreshSource(selectedFeature.getNumberProperty(CONTACT_ID).intValue());
    }

    private void setNewMarkerSelected(String featureId) {
        Feature feature = getFeatureWithId(featureId);
        feature.addBooleanProperty(MARKER_SELECTED, true);
        selectedFeature = feature;
        refreshSource(selectedFeature.getNumberProperty(CONTACT_ID).intValue());
    }

    private Feature getFeatureWithId(String id) {
        for (Map.Entry<String, LinkedList<Feature>> e : featureCollections.entrySet()) {
            String key = e.getKey();
            if (key.startsWith(LINE_FEATURE_LIST)) {
                continue;
            }
            LinkedList<Feature> featureCollection = e.getValue();
            for (Feature f : featureCollection) {
                if (f.id().equals(id)) {
                    return f;
                }
            }
        }
        return null;
    }

}