/*
 * Copyright (c) 2016, Aegir Labs LLC. All rights reserved.
 */
package com.aegir.demo;

import android.animation.ValueAnimator;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.compose.ui.platform.ComposeView;
import androidx.fragment.app.Fragment;

import com.aegir.demo.ui.BasemapMode;
import com.aegir.demo.ui.MapOverlayHost;
import com.aegir.demo.ui.MapOverlayState;
import com.aegir.demo.ui.UnitInfo;

import android.util.DisplayMetrics;
import android.util.Log;
import android.util.SizeF;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import android.view.animation.DecelerateInterpolator;
import android.widget.FrameLayout;
import android.widget.TextView;

import com.aegir.demo.fragments.DirectionsFragment;
import com.aegir.demo.fragments.FloorMapUnitsInfoFragment;
import com.aegir.demo.fragments.FloorSelectorFragment;
import com.aegir.demo.fragments.TextDirectionsFragment;
import com.aegir.demo.model.BaseFloorItem;
import com.aegir.demo.model.BaseFloorItemComparator;
import com.aegir.demo.model.FloorItem;
import com.aegir.vmms.android.annotations.FloorChangePointAnnotation;
import com.aegir.vmms.android.annotations.LandmarkAnnotation;
import com.aegir.vmms.android.annotations.MapAnnotation;
import com.aegir.vmms.android.annotations.PointAnnotation;
import com.aegir.vmms.android.interfaces.MapViewDelegate;
import com.aegir.vmms.android.interfaces.WalkingPathOverlayDelegate;
import com.aegir.vmms.android.model.VMDAssetFileCollection;
import com.aegir.vmms.android.model.VMDUrlFileCollection;
import com.aegir.vmms.vmd.model.VMDFileCollection;
import com.aegir.vmms.vmd.enums.MapProvider;
import com.aegir.vmms.android.ui.RasterMapView;
import com.aegir.vmms.android.ui.VectorMapView;
import com.aegir.vmms.util.VMDUtil;
import com.aegir.vmms.util.WalkingPathOverlayUtil;
import com.google.android.gms.maps.CameraUpdateFactory;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.SupportMapFragment;
import com.google.android.gms.maps.model.CameraPosition;
import com.aegir.vmms.android.model.AndroidCallback;
import com.aegir.vmms.android.model.Styling.VenueLayerStyle;
import com.aegir.vmms.android.model.Styling.VenueStyle;
import com.aegir.vmms.android.model.VMDAssetFile;
import com.aegir.vmms.android.ui.MapView;
import com.aegir.vmms.android.ui.model.VMMapViewLoadConfig;
import com.aegir.vmms.android.ui.wayfinding.BaseWalkingPathOverlay;
import com.aegir.vmms.android.ui.wayfinding.VectorWalkingPathOverlay;
import com.aegir.vmms.android.ui.wayfinding.VenueWayfindingConstants;
import com.aegir.vmms.vmd.MapDelegate;
import com.aegir.vmms.vmd.enums.VMProcessingMode;
import com.aegir.vmms.vmd.interfaces.VMDCallback;
import com.aegir.vmms.vmd.model.BaseMapBuilding;
import com.aegir.vmms.vmd.model.BaseMapBuildingFloor;
import com.aegir.vmms.vmd.model.CustomMapInfo;
import com.aegir.vmms.vmd.model.Map;
import com.aegir.vmms.vmd.model.MapBuilding;
import com.aegir.vmms.vmd.model.MapBuildingFloor;
import com.aegir.vmms.vmd.model.MapOutdoorBuildingFloor;
import com.aegir.vmms.vmd.model.MapUnit;
import com.aegir.vmms.vmd.model.VMDFileCollection;
import com.aegir.vmms.wayfinding.WayfindingDelegate;
import com.aegir.vmms.wayfinding.enums.MapUnitType;
import com.aegir.vmms.wayfinding.model.LatLng;
import com.aegir.vmms.wayfinding.model.LatLngBounds;
import com.aegir.vmms.wayfinding.model.MapDirectionStep;
import com.aegir.vmms.wayfinding.model.MapDirectionStepList;
import com.aegir.vmms.wayfinding.model.TurnByTurnDirectionOptions;
import com.aegir.vmms.wayfinding.model.WayfindingOptions;
import com.aegir.vmms.wayfinding.model.Waypath;
import com.aegir.vmms.wayfinding.model.WaypathSegment;
import com.aegir.vmms.wayfinding.model.Waypoint;
import com.aegir.vmms.android.internal.ui.rendering.NativeInterface;
import org.maplibre.android.annotations.InfoWindow;
import org.maplibre.android.maps.Style;
import org.maplibre.android.style.layers.FillExtrusionLayer;
import org.maplibre.android.style.layers.Layer;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;

/**
 * This is the app's main activity that ties everything together.
 * Demonstrate wayfinding using the VMSDK and Google Maps
 * <p>
 * Steps to get your map working (search for 'Step #: in code')
 * Step 0: Add a VectorMapView or RasterMapView to your layout file.
 * Step 1: Do some initializing before inflating your views.
 * Step 2: Create your mapview.
 * Step 3: Load data from VMD files.
 * Step 4: Provide necessary data for your MapView.
 * </p>
 */
public class MapActivity extends AppCompatActivity implements
        TextDirectionsFragment.OnTextDirectionsFragmentInteractionListener,
        FloorSelectorFragment.OnFloorFragmentInteractionListener,
        MapDelegate,
        WayfindingDelegate,
        MapViewDelegate,
        WalkingPathOverlayDelegate {

    // region Constants

    private static final String TAG = MapActivity.class.getSimpleName();

    private static final String DEFAULT_VENUE_ID = "AEGIRH";

    // Zoom margin subtracted from the computed bounds-fit so the ground-floor
    // outline isn't framed edge-to-edge on initial load.
    private static final float CAMERA_FIT_PADDING = 1.5f;
    // endregion

    // region Fields

    private Waypath waypath;
    private BaseWalkingPathOverlay walkingPathOverlay;
    private CustomMapInfo customMapInfo;
    protected Map vmd;
    private int topBarHeightClosed;
    private int topBarHeightOpen;
    private boolean topBarIsOpen;
    private boolean hasDirections;
    private ProgressDialog loadingDialog;
    private FloorSelectorFragment floorSelectorFragment;
    private VMDCallback callback;
    private VenueStyle venueStyle;
    private DirectionsFragment directionsFragment;

    private long performanceStartTime;
    private boolean mapViewLoadError;

    protected MapView mapView;
    protected Bundle savedInstanceState;

    private List<BaseFloorItem> indoorFloors = new ArrayList<>();
    private List<BaseFloorItem> outdoorFloors = new ArrayList<>();

    private boolean isFloorUnitsFragmentVisible = false;

    private boolean isFloorSelectorFragmentVisible = false;

    private boolean isDirectionsFragmentVisible = false;

    private MapProvider defaultMapProvider = MapProvider.GOOGLE_MAPS;

    private boolean isUsingLocalAssets = true;

    /** Absolute path of a venue extracted from a bundled zip asset; null when serving loose assets or a URL. */
    private String extractedVenuePath;

    private String mVenue = AppConstants.VENUE_ID;
    private String mCloudfrontUrl = AppConstants.AMAZON_CLOUDFRONT_URL;

    private GoogleMap googleMap;
    private SupportMapFragment googleMapFragment;

    private BaseMapBuilding selectedBuilding;

    private MapOverlayState overlayState;

    // Per-building active floor. Every non-selected building stays pinned to its
    // lowest-ordinal (ground) floor so other buildings render as ground-floor
    // shapes (matching iOS) instead of a solid footprint mass + name label.
    // activeIndoorFloors is always the union of these values.
    private final java.util.LinkedHashMap<String, MapBuildingFloor> activeFloorByBuilding =
            new java.util.LinkedHashMap<>();

    // Dual-basemap stacks (per android-basemap-toggle plan)
    private MapView mapLibreVenueView;
    private MapView googleVenueView;
    private boolean googleStackInitialized = false;
    private BasemapMode currentMode = BasemapMode.MAPLIBRE;
    private LatLng lastCameraTarget;
    private float lastCameraZoom;
    private double lastCameraBearing;
    private double lastCameraTilt;
    private boolean hasCapturedCamera = false;

    // endregion

    // region Constructors

    /**
     * Required empty public constructor
     */
    public MapActivity() {
    }


    // endregion

    // region Protected Methods

    /**
     * @param savedInstanceState the saved state
     */
    @Override
    protected void onCreate(Bundle savedInstanceState) {

        this.savedInstanceState = savedInstanceState;
        //Step 1:

        //This method MUST be called before super.onCreate()
        MapView.init(getApplicationContext());

        super.onCreate(savedInstanceState);

        setLayout();

        if (getSupportActionBar() != null) {
            getSupportActionBar().hide();
        }

        // Defensively hide legacy XML widgets so the Compose overlay owns the UI.
        View v;
        if ((v = findViewById(R.id.indoorFloorDropdown)) != null) v.setVisibility(View.GONE);
        if ((v = findViewById(R.id.outdoorFloorDropdown)) != null) v.setVisibility(View.GONE);
        if ((v = findViewById(R.id.fl_directions_action_container)) != null) v.setVisibility(View.GONE);
        if ((v = findViewById(R.id.fl_show_directions)) != null) v.setVisibility(View.GONE);
        if ((v = findViewById(R.id.btn_directions_action)) != null) v.setVisibility(View.GONE);

        googleMapFragment = (SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map);
        if (googleMapFragment != null) {
            googleMapFragment.getMapAsync(map -> {
                Log.d(TAG, "Google map is ready");
                map.setMapType(GoogleMap.MAP_TYPE_NORMAL);
                map.setBuildingsEnabled(false);
                map.setMinZoomPreference(AppConstants.MAP_MIN_ZOOM);
                map.setMaxZoomPreference(AppConstants.MAP_MAX_ZOOM);
                MapActivity.this.googleMap = map;
            });
        }

        //Move the directions off screen
        hideDirections(null);
        //Move the floor selector off screen
        hideFloorSelector(null);

        overlayState = new MapOverlayState();
        overlayState.setBasemapMode(currentMode);
        String venueIdFromIntent = getIntent().getStringExtra("venue_id");
        if (venueIdFromIntent != null && !venueIdFromIntent.isEmpty()) {
            overlayState.setNavTitle(venueIdFromIntent + " - " + basemapLabel(currentMode));
        }
        ComposeView composeView = findViewById(R.id.compose_overlay);
        MapOverlayHost.install(composeView, overlayState, new MapOverlayHost.Callbacks() {
            @Override
            public void selectFloor(BaseMapBuildingFloor floor) {
                runOnUiThread(() -> {
                    updateFloor(floor);
                    overlayState.setCurrentFloor(floor);
                });
            }

            @Override
            public void toggleWayfinding() {
                runOnUiThread(() -> handleToggleWayfinding());
            }

            @Override
            public void wayfindingGo() {
                runOnUiThread(() -> handleWayfindingGo());
            }

            @Override
            public void wayfindingClear() {
                runOnUiThread(() -> handleWayfindingClear());
            }

            @Override
            public void showSteps() {
                overlayState.setDirectionsSheetShown(true);
            }

            @Override
            public void dismissSteps() {
                overlayState.setDirectionsSheetShown(false);
            }

            @Override
            public void back() {
                runOnUiThread(() -> finish());
            }

            @Override
            public void reload() {
                runOnUiThread(() -> recreate());
            }

            @Override
            public void selectBasemap(BasemapMode mode) {
                runOnUiThread(() -> toggleBasemap(mode));
            }

            @Override
            public void showFloorList() {
                runOnUiThread(() -> {
                    if (overlayState != null) overlayState.setFloorListSheetShown(true);
                });
            }

            @Override
            public void dismissFloorList() {
                runOnUiThread(() -> {
                    if (overlayState != null) overlayState.setFloorListSheetShown(false);
                });
            }
        });

        floorSelectorFragment = (FloorSelectorFragment) getSupportFragmentManager().findFragmentById(R.id.frag_floor_selector);
        //These values are used to animate the top bar when hiding/showing the start/end waypoints used for directions
        int TOP_BAR_HEIGHT_CLOSED_DPI = 50;
        topBarHeightClosed = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, TOP_BAR_HEIGHT_CLOSED_DPI, getResources().getDisplayMetrics());
        int TOP_BAR_HEIGHT_OPEN_DPI = 180;
        topBarHeightOpen = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, TOP_BAR_HEIGHT_OPEN_DPI, getResources().getDisplayMetrics());


        //Load all map and wayfinding data
        doInitialSetup();
    }

    @Override
    public boolean onSupportNavigateUp() {
        finish();
        return true;
    }


    /**
     * Entry point invoked once after VMD load. Sets up the default (MapLibre) basemap stack.
     * The Google Maps stack is initialized lazily on first toggle via {@link #setupGoogleStack()}.
     */
    protected void initializeMapView() {
        setupMapLibreStack();
    }

    /**
     * Native MapLibre basemap (MapTiler streets) + venue overlay rendered in a single MapView.
     * Mirrors the iOS demo architecture and is the default initial view.
     */
    protected void setupMapLibreStack() {
        if (mapLibreVenueView != null) {
            return;
        }

        // MapLibre stack does not use the Google Maps fragment as the basemap.
        // We hide the fragment to avoid wasted tile fetching, but keep the googleMap reference
        // alive so a subsequent toggle to Google can re-apply the camera without re-init.
        if (googleMapFragment != null && googleMapFragment.isVisible()) {
            getSupportFragmentManager().beginTransaction()
                    .setTransition(androidx.fragment.app.FragmentTransaction.TRANSIT_FRAGMENT_CLOSE)
                    .hide(googleMapFragment)
                    .commitNow();
        }

        if (this.vmd.getProcessingMode() == VMProcessingMode.DEFAULT) {
            mapLibreVenueView = new VectorMapView(this, true);
        } else {
            mapLibreVenueView = new RasterMapView(this, true);
        }
        mapLibreVenueView.setLayoutParams(new FrameLayout.LayoutParams(
                FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));

        FrameLayout container = findViewById(R.id.fl_map_container);
        container.addView(mapLibreVenueView, 1);
        mapLibreVenueView.setDelegate(this);

        String maptilerKey = getString(R.string.maptiler_api_key);
        String styleUrl = String.format(Locale.ENGLISH, AppConstants.MAPTILER_STYLE_TEMPLATE, maptilerKey);
        mapLibreVenueView.onCreate(this.savedInstanceState, this, styleUrl);

        this.mapView = mapLibreVenueView;
        initializeWalkingPathOverlay(mapLibreVenueView);
    }

    /**
     * Lazy-initializes the Google Maps + WebView VectorMapView stack on first toggle.
     */
    protected void setupGoogleStack() {
        if (googleStackInitialized) {
            return;
        }
        if (this.vmd.isGeolocated() && getResources().getString(R.string.google_maps_key).isEmpty()) {
            this.didFailToLoadMapView(null, new Exception("Google Maps API Key not configured."));
            return;
        }

        if (googleMapFragment != null && !googleMapFragment.isVisible()) {
            getSupportFragmentManager().beginTransaction()
                    .setTransition(androidx.fragment.app.FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
                    .show(googleMapFragment)
                    .commitNow();
        }

        if (this.vmd.getProcessingMode() == VMProcessingMode.DEFAULT) {
            googleVenueView = new VectorMapView(this, false);
        } else {
            googleVenueView = new RasterMapView(this, false);
        }
        googleVenueView.setLayoutParams(new FrameLayout.LayoutParams(
                FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));

        FrameLayout container = findViewById(R.id.fl_map_container);
        container.addView(googleVenueView, 1);
        googleVenueView.setDelegate(this);
        googleVenueView.onCreate(this.savedInstanceState, this);

        googleStackInitialized = true;
    }

    /**
     * Toggles between MapLibre (native) and Google Maps (WebView VectorMapView) basemap stacks.
     * Captures the current camera position from the active stack and re-applies it to the target,
     * pausing the inactive stack to halt tile fetching.
     */
    protected void toggleBasemap(BasemapMode target) {
        if (target == null || target == currentMode) {
            return;
        }
        captureCurrentCamera();

        if (target == BasemapMode.GOOGLE_MAPS) {
            if (!googleStackInitialized) {
                setupGoogleStack();
            }
            if (mapLibreVenueView != null) {
                mapLibreVenueView.setVisibility(View.GONE);
                try { mapLibreVenueView.onPause(); } catch (Exception ignored) {}
            }
            if (googleMapFragment != null && !googleMapFragment.isVisible()) {
                getSupportFragmentManager().beginTransaction()
                        .setTransition(androidx.fragment.app.FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
                        .show(googleMapFragment)
                        .commitNow();
            }
            if (googleVenueView != null) {
                googleVenueView.setVisibility(View.VISIBLE);
                try { googleVenueView.onResume(); } catch (Exception ignored) {}
            }
            this.mapView = googleVenueView;
        } else {
            if (googleVenueView != null) {
                googleVenueView.setVisibility(View.GONE);
                try { googleVenueView.onPause(); } catch (Exception ignored) {}
            }
            if (googleMapFragment != null && googleMapFragment.isVisible()) {
                getSupportFragmentManager().beginTransaction()
                        .setTransition(androidx.fragment.app.FragmentTransaction.TRANSIT_FRAGMENT_CLOSE)
                        .hide(googleMapFragment)
                        .commitNow();
            }
            if (mapLibreVenueView != null) {
                mapLibreVenueView.setVisibility(View.VISIBLE);
                try { mapLibreVenueView.onResume(); } catch (Exception ignored) {}
            }
            this.mapView = mapLibreVenueView;
        }

        // Re-bind walking path overlay so wayfinding interacts with the active mapView.
        if (this.mapView != null) {
            initializeWalkingPathOverlay(this.mapView);
        }
        applyCapturedCamera(this.mapView);

        currentMode = target;
        if (overlayState != null) {
            overlayState.setBasemapMode(target);
            overlayState.setNavTitle(mVenue + " - " + basemapLabel(target));
        }

        // setMapPosition() on the WebView VectorMapView does not always re-fire
        // didChangeCameraPosition synchronously, so the underlying Google basemap can
        // sit at default coords. Move it directly here when toggling into Google.
        if (target == BasemapMode.GOOGLE_MAPS && googleMap != null && hasCapturedCamera && lastCameraTarget != null) {
            double adjustedZoom = lastCameraZoom + 1;
            CameraPosition cam = new CameraPosition.Builder(googleMap.getCameraPosition())
                    .target(new com.google.android.gms.maps.model.LatLng(lastCameraTarget.latitude, lastCameraTarget.longitude))
                    .zoom((float) adjustedZoom)
                    .bearing((float) lastCameraBearing)
                    .tilt((float) lastCameraTilt)
                    .build();
            googleMap.moveCamera(CameraUpdateFactory.newCameraPosition(cam));
        }
    }

    /**
     * Captures the most recent camera state from the active mapView. Camera state is tracked
     * via {@link #didChangeCameraPosition} since {@link MapView} does not expose a getter.
     */
    protected void captureCurrentCamera() {
        // No-op: camera state is continuously captured via didChangeCameraPosition.
    }

    /**
     * Re-applies the most recently captured camera (target/zoom/bearing) to the supplied mapView.
     */
    protected void applyCapturedCamera(MapView mv) {
        if (!hasCapturedCamera || mv == null || lastCameraTarget == null) {
            return;
        }
        mv.setMapPosition(lastCameraTarget, lastCameraBearing, lastCameraZoom);
    }

    private static String basemapLabel(BasemapMode mode) {
        return mode == BasemapMode.MAPLIBRE ? "MapLibre" : "Google";
    }

    /**
     *
     */
    protected void initializeWalkingPathOverlay() {
        initializeWalkingPathOverlay(this.mapView);
    }

    /**
     * Re-bind the walking path overlay to the supplied mapView. Called once per stack creation
     * and on basemap toggle so wayfinding markers/paths follow the active venue view.
     */
    protected void initializeWalkingPathOverlay(MapView targetView) {
        if (this.vmd.getProcessingMode() != VMProcessingMode.LEGACY && targetView != null) {
            this.walkingPathOverlay = new VectorWalkingPathOverlay(targetView);
            this.walkingPathOverlay.setDelegate(MapActivity.this);
            this.walkingPathOverlay.setMap(this.vmd);
        }
    }

    /**
     *
     */
    protected void setLayout() {
        setContentView(R.layout.activity_google_map);
    }

    /**
     *
     */
    protected void doInitialSetup() {
        //Step 3:
        this.loadingDialog = ProgressDialog.show(this, "Loading", "Map is loading...");
        this.loadingDialog.show();

        //Will receive callbacks from the sdk on the main thread
        callback = new AndroidCallback(new Handler(Looper.getMainLooper()));

        //Resolve venue ID and base URL from intent extras (set by VenueListActivity).
        String intentVenueId = getIntent().getStringExtra("venue_id");
        String intentBaseUrl = getIntent().getStringExtra("base_url");
        if (intentBaseUrl != null && !intentBaseUrl.isEmpty()) {
            mCloudfrontUrl = intentBaseUrl;
        }
        if (intentVenueId != null && !intentVenueId.isEmpty()) {
            mVenue = intentVenueId;
        }
        this.venueStyle = this.loadVenueStyle(mVenue);

        //Check for bundled assets first, fall back to URL loading
        boolean hasLocalAssets = false;
        AssetManager assetManager = getApplicationContext().getAssets();
        try {
            String[] files = assetManager.list(String.format("venue_map_%s", mVenue));
            hasLocalAssets = files != null && files.length > 0;
        } catch (Exception ignored) {}

        if (hasLocalAssets) {
            try {
                String cacheDir = getApplicationContext().getCacheDir() + File.separator + System.currentTimeMillis();
                VMDFileCollection fileCollection = new VMDAssetFileCollection(assetManager, mVenue, defaultMapProvider, cacheDir);
                Map.load(fileCollection, this, callback);
            } catch (Exception e) {
                this.mapViewLoadError = true;
                this.loadingDialog.hide();
                showAlertDialog("Failed to load map.", e.getMessage(), null);
            }
        } else if (VenueAssets.hasZipAsset(getApplicationContext(), mVenue)) {
            // Bundled venues ship zipped (APK 65,535-entry limit); extract off the
            // main thread on first use, then load from app storage.
            new Thread(() -> {
                try {
                    File venueDir = VenueAssets.ensureExtracted(getApplicationContext(), mVenue);
                    String cacheDir = getApplicationContext().getCacheDir() + File.separator + System.currentTimeMillis();
                    VMDFileCollection fileCollection = new VMDFileCollection();
                    fileCollection.load(mVenue, venueDir.getAbsolutePath() + File.separator, defaultMapProvider, cacheDir);
                    runOnUiThread(() -> {
                        extractedVenuePath = venueDir.getAbsolutePath();
                        Map.load(fileCollection, MapActivity.this, callback);
                    });
                } catch (Exception e) {
                    runOnUiThread(() -> {
                        this.mapViewLoadError = true;
                        this.loadingDialog.hide();
                        showAlertDialog("Failed to load map.", e.getMessage(), null);
                    });
                }
            }, "venue-extract").start();
        } else {
            try {
                isUsingLocalAssets = false;
                String cacheFolder = getApplicationContext().getCacheDir() + File.separator + System.currentTimeMillis();
                loadMapFromUrl(cacheFolder);
            } catch (Exception mue) {
                this.didFailToLoadMapWithError(mue);
            }
        }
    }

    /**
     * @return
     */
    protected VenueStyle loadVenueStyle(String venueId) {
        //configure styling
        VMDAssetFile venueStyleConfig = new VMDAssetFile("style_default.json", getAssets());
        return new VenueStyle(venueStyleConfig, venueId);
    }
    // endregion

    // region Public Methods


    /**
     * @param newLocation
     * @param newZoom
     * @param newBearing
     * @param newTilt
     */
    @Override
    public void didChangeCameraPosition(LatLng newLocation, float newZoom, double newBearing, double newTilt) {
        Log.i(TAG, String.format("Changing camera position to location:(%.6f,%.6f) zoom: %.6f, bearing: %f", newLocation.latitude, newLocation.longitude, newZoom, newBearing));
        if (overlayState != null) {
            overlayState.setBearing(newBearing);
        }

        lastCameraTarget = newLocation;
        lastCameraZoom = newZoom;
        lastCameraBearing = newBearing;
        lastCameraTilt = newTilt;
        hasCapturedCamera = true;

        if (currentMode == BasemapMode.GOOGLE_MAPS && googleMap != null) {
            double adjustedZoom = newZoom + 1;
            CameraPosition newCam = new CameraPosition.Builder(googleMap.getCameraPosition())
                    .target(new com.google.android.gms.maps.model.LatLng(newLocation.latitude, newLocation.longitude))
                    .zoom((float) adjustedZoom)
                    .bearing((float) newBearing)
                    .tilt((float) newTilt)
                    .build();
            googleMap.moveCamera(CameraUpdateFactory.newCameraPosition(newCam));
            if (googleMapFragment != null && googleMapFragment.getView() != null) {
                googleMapFragment.getView().setAlpha(adjustedZoom > 21 ? 0.0f : 1.0f);
            }
        }
    }

    @Override
    public Boolean    shouldChangeCameraPosition(LatLng newLocation, double newBearing, double newTilt) {
        return true;
    }

    /**
     * Called when an action was received in the DirectionsFragment
     *
     * @param sender the button that was clicked
     */
    public void handleDirectionsAction(View sender) {

        if (!topBarIsOpen) {
            showHideTopBar(false);
        }
        if (walkingPathOverlay == null) {
            return;
        }

        if (!walkingPathOverlay.isSelectingPoints()) {
            walkingPathOverlay.setSelectingPoints(true);
        } else {
            if (walkingPathOverlay.getStartingWaypoint() != null && walkingPathOverlay.getEndingWaypoint() != null) {
                if (walkingPathOverlay.getStartingWaypoint().equals(walkingPathOverlay.getEndingWaypoint())) {
                    showAlertDialog("Invalid Points", "The start and end point cannot be the same.", (dialog, which) -> {
                        //walkingPathOverlay.clear();
                        walkingPathOverlay.setSelectingPoints(true);
                        updateDirectionTextViews();
                    });
                } else {
                    //NOTE: Finding the waypath is (sometimes) about 7x slower when DEBUGGING on physical device
                    performanceStartTime = System.currentTimeMillis();
                    WayfindingOptions options = new WayfindingOptions(true, false);
                    mapView.getMap().findWaypathBetweenWaypoints(walkingPathOverlay.getStartingWaypoint(), walkingPathOverlay.getEndingWaypoint(), options, MapActivity.this, callback);
                }
            }
        }
    }

    /**
     * @param sender the button that was clicked
     */
    public void goBack(View sender) {
        super.getOnBackPressedDispatcher().onBackPressed();
    }

    /**
     * @param sender the button that was clicked
     */
    public void showDirections(View sender) {
        FrameLayout flTextDirContainer = (FrameLayout) findViewById(R.id.fl_text_directions_container);
        animateOverlay(flTextDirContainer, false, sender);
        isDirectionsFragmentVisible = true;
    }

    /**
     * @param sender the button that was clicked
     */
    public void hideDirections(View sender) {
        FrameLayout flTextDirContainer = (FrameLayout) findViewById(R.id.fl_text_directions_container);
        animateOverlay(flTextDirContainer, true, sender);
        isDirectionsFragmentVisible = false;
    }

    /**
     * @param sender the button that was clicked
     */
    public void showIndoorFloorSelector(View sender) {
        FrameLayout flFloorSelector = (FrameLayout) findViewById(R.id.fl_floor_selector_container);
        floorSelectorFragment.setAdapter(indoorFloors);
        animateOverlay(flFloorSelector, false, sender);
        isFloorSelectorFragmentVisible = true;
    }


    /**
     * @param sender the button that was clicked
     */
    public void showOutdoorFloorSelector(View sender) {
        FrameLayout flFloorSelector = (FrameLayout) findViewById(R.id.fl_floor_selector_container);
        floorSelectorFragment.setAdapter(outdoorFloors);
        animateOverlay(flFloorSelector, false, sender);
        isFloorSelectorFragmentVisible = true;
    }

    /**
     * @param sender the button that was clicked
     */
    public void hideFloorSelector(View sender) {
        if (isFloorUnitsFragmentVisible) {
            Fragment floorUnitsFragment = getSupportFragmentManager().findFragmentById(R.id.frag_floor_units);
            if (floorUnitsFragment != null) {
                getSupportFragmentManager().beginTransaction().remove(floorUnitsFragment).commit();
                isFloorUnitsFragmentVisible = false;
                findViewById(R.id.frag_floor_units).setVisibility(View.GONE);
            }
        } else {
            FrameLayout flFloorSelector = (FrameLayout) findViewById(R.id.fl_floor_selector_container);
            animateOverlay(flFloorSelector, true, sender);
            isFloorSelectorFragmentVisible = false;
        }
    }

    /**
     * @param segment
     */
    public void onSegmentSelected(WaypathSegment segment) {
        MapBuildingFloor newFloor = mapView.getMap().findFloorWithId(segment.getFloorId());
        //Only do this if they selected a different floor and if it's not an outdoor floor
        if (walkingPathOverlay != null) {
            if (newFloor != null
                    && (walkingPathOverlay.getCurrentFloor() != null && !walkingPathOverlay.getCurrentFloor().getId().equals(newFloor.getId())
                    || walkingPathOverlay.getCurrentOutdoorFloor() != null && !walkingPathOverlay.getCurrentOutdoorFloor().getId().equals(newFloor.getId()))) {
                updateFloor(newFloor);
            }

            walkingPathOverlay.onSegmentSelected(segment);
        }
    }

    // endregion

    // region Private Methods

    private void loadMapFromUrl(String cacheFolder) {
        kotlin.jvm.functions.Function1<VMDFileCollection, kotlin.Unit> onComplete = fileCollection -> {
            Map.load(fileCollection, this, callback);
            return null;
        };
        new VMDUrlFileCollection(mCloudfrontUrl, mVenue, cacheFolder, defaultMapProvider, onComplete);
    }

    /**
     * This code shows an example of how one might apply
     * custom style code to a single map element
     *
     * @param floor the floor to add the styles to
     * @since 1.1
     */
    private void applyCustomStylesToFloor(BaseMapBuildingFloor floor) {
        String[] keywords = {"Meeting", "Room"};
        for (MapUnit unit : floor.getMapUnits()) {
            //e.g. highlight all the Meeting rooms

            if (Arrays.stream(keywords).anyMatch(k -> unit.getName().contains(k))) {
                Log.i(TAG, "Apply custom color to: " + unit.getId());
                VenueLayerStyle style = new VenueLayerStyle();
                style.setFillColor(Color.parseColor("#CF9FFF"));
                style.setOutlineColor(Color.BLACK);
                style.setFontSize(24f);
                this.mapView.setStyleForMapUnit(style, unit);
            }
        }
    }

    /**
     * Create test annotations
     *
     * @param floor
     */
    private void addTestAnnotationsToFloor(BaseMapBuildingFloor floor) {
        for (MapUnit unit : floor.getMapUnits()) {
            if (unit.getCategory().equals("GUEST_ROOM")) {
                PointAnnotation annotation = new PointAnnotation();
                annotation.setFloorId(floor.getId());
                annotation.setFloorNumber(floor.getOrdinal());
                annotation.setPosition(unit.getHotSpotLocation() != null ? unit.getHotSpotLocation() : unit.getCenterLocation());
                annotation.setId(unit.getId());
                annotation.setBitmapIcon(this.getTestIcon());
                annotation.setIconScale(getResources().getDisplayMetrics().density);
                if (!this.mapView.isUsingDefaultMapProvider()) {
                    //For simple icons, use setIconPath, otherwise use setHtml for more complex content
//                  annotation.setIconPath("asset://icon_bed.png");
                    //add popups to annotations with HTML content
                    String popupHtml = "<div><b><u>Room " + unit.getName() + "</u></b></div>" +
                            "<table>" +
                            "<thead><tr><th>Property</th><th>Value</th></tr></thead>" +
                            "<tbody>" +
                            "<tr><td>Id</td><td>" + unit.getId() + "</td> </tr>" +
                            "<tr><td>Name</td><td>" + unit.getName() + "</td> </tr>" +
                            "<tr><td>Floor</td><td>" + unit.getFloor().getId() + "</td></tr>" +
                            "<tr><td>Category</td><td>" + unit.getCategory() + "</td> </tr>" +
                            "</tbody>" +
                            "</table>";
                    annotation.setPopupHtml(popupHtml);
                }
                this.mapView.addMarker(annotation);
            }
        }
    }

    /**
     *
     * @return
     */
    private Bitmap getTestIcon()
    {
        return BitmapFactory.decodeResource(getResources(), com.aegir.vmms.android.R.drawable.icon_elevator);
    }

    /**
     * @param direction
     * @param destination
     * @return
     */
    //Here just to demonstrate how to provide a custom image.
    private Bitmap getFloorChangeBitmap(String direction, String destination) {
        int id = com.aegir.vmms.android.R.layout.view_floor_change;
        View customMarkerView = ((LayoutInflater) mapView.getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE)).inflate(id, null);

        TextView directionTextView = (TextView) customMarkerView.findViewById(com.aegir.vmms.android.R.id.tvFloorChangeDirection);
        TextView destinationTextView = (TextView) customMarkerView.findViewById(com.aegir.vmms.android.R.id.tvNextFloor);

        directionTextView.setText(direction);
        destinationTextView.setText(destination);

        int floorChangeHeightDP = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 55, mapView.getContext().getResources().getDisplayMetrics());
        customMarkerView.measure(View.MeasureSpec.UNSPECIFIED, floorChangeHeightDP);// View.MeasureSpec.UNSPECIFIED);
        customMarkerView.layout(0, 0, customMarkerView.getMeasuredWidth(), floorChangeHeightDP);//customMarkerView.getMeasuredHeight());
        customMarkerView.buildDrawingCache();
        Bitmap returnedBitmap = Bitmap.createBitmap(customMarkerView.getMeasuredWidth(), floorChangeHeightDP,// customMarkerView.getMeasuredHeight(),
                Bitmap.Config.ARGB_8888);

        Canvas canvas = new Canvas(returnedBitmap);
        canvas.drawColor(Color.WHITE, PorterDuff.Mode.SRC_IN);
        Drawable drawable = customMarkerView.getBackground();
        if (drawable != null)
            drawable.draw(canvas);
        customMarkerView.draw(canvas);

        return returnedBitmap;
    }

    /**
     * @param context
     * @param filePath
     * @return
     */
    private static Bitmap getBitmapFromAsset(Context context, String filePath) {
        Log.d(TAG, "Getting bitmap from asset: " + filePath);
        AssetManager assetManager = context.getAssets();

        InputStream istr = null;
        Bitmap bitmap = null;
        try {

            istr = assetManager.open(filePath);
            bitmap = BitmapFactory.decodeStream(istr);
        } catch (IOException e) {
            // handle exception
            Log.e(TAG, "Error getting bitmap from asset: " + filePath + " - " + e.getMessage());
        } finally {
            if (istr != null) {
                try {
                    istr.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return bitmap;
    }

    /**
     * @param px
     * @return
     */
    private int toDP(int px) {
        DisplayMetrics metrics = this.getResources().getDisplayMetrics();
        return Math.round(px / (metrics.xdpi / DisplayMetrics.DENSITY_DEFAULT));
    }

    /**
     *
     */
    private void updateUI() {
        try {
            if (walkingPathOverlay == null) {
                return;
            }
            if (hasDirections) {

                //update the directions label with the description of the ending waypoint node
                Waypoint endNode = walkingPathOverlay.getEndingWaypoint();
                String name = walkingPathOverlay.getEndingWaypoint().getName();
                if (endNode != null && !endNode.getPublicDescription().isEmpty()) {
                    name = endNode.getPublicDescription();
                }

                Log.i(TAG, String.format(Locale.ENGLISH, "Node name for id '%s' is '%s'", endNode.getId(), name));
                ((TextView) findViewById(R.id.tv_header)).setText(String.format(Locale.ENGLISH, "Directions: %1$s", name == null ? "Room" : name));

                findViewById(R.id.btn_directions_action).setVisibility(View.INVISIBLE);

            } else {
                ((TextView) findViewById(R.id.tv_header)).setText("Lokion");
                if (walkingPathOverlay != null) {
                    if (walkingPathOverlay.getStartingWaypoint() != null && walkingPathOverlay.getEndingWaypoint() != null) {
                        findViewById(R.id.btn_directions_action).setBackgroundResource(R.drawable.btn_directions_inactive);
                    } else {
                        findViewById(R.id.btn_directions_action).setBackgroundResource(R.drawable.btn_directions_red);
                    }
                } else {
                    findViewById(R.id.btn_directions_action).setBackgroundResource(R.drawable.btn_directions_red);
                }
                findViewById(R.id.btn_directions_action).setVisibility(View.GONE);
            }
        } catch (Exception e) {
            Log.e(TAG, "Error", e);
        }
    }

    /**
     * Animates given framelayout (TextDirections, FloorSelector)
     *
     * @param frameLayout
     * @param shouldHide
     * @param sender
     */
    private void animateOverlay(FrameLayout frameLayout, boolean shouldHide, View sender) {
        if (shouldHide) {
            frameLayout.animate().y(getBaseContext().getResources().getDisplayMetrics().heightPixels).setInterpolator(new DecelerateInterpolator()).setDuration(sender == null ? 0 : 250)
                    .withEndAction(new Runnable() {
                        @Override
                        public void run() {
                        }
                    });
        } else {
            frameLayout.animate().y(0).setInterpolator(new DecelerateInterpolator()).setDuration(250)
                    .withEndAction(new Runnable() {
                        @Override
                        public void run() {
                        }
                    });
        }
    }

    /**
     * If directions available, this method will move the "Show Text Directions" button so that it is visible at the bottom
     * Or move it below the visible screen if no directions are available
     *
     * @param shouldHide
     */
    private void showHideDirectionsButton(boolean shouldHide) {
        DisplayMetrics dm = getBaseContext().getResources().getDisplayMetrics();
        int SHOW_DIRECTIONS_BUTTON_HEIGHT = 50;
        int height = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, SHOW_DIRECTIONS_BUTTON_HEIGHT, getResources().getDisplayMetrics());

        int start = 0, end = 0;
        if (shouldHide) {
            if (hasDirections) {
                start = height;
            }
        } else {
            end = height;
        }

        ValueAnimator va = ValueAnimator.ofInt(start, end);
        va.setDuration(125);
        va.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            public void onAnimationUpdate(ValueAnimator animation) {
                Integer value = (Integer) animation.getAnimatedValue();
                FrameLayout fl = (FrameLayout) findViewById(R.id.fl_show_directions);
                fl.getLayoutParams().height = value.intValue();
                fl.requestLayout();
            }
        });
        va.start();
    }

    /**
     *
     */
    private void clearDirections() {
        TextDirectionsFragment textDirectionsFragment = (TextDirectionsFragment) getSupportFragmentManager().findFragmentById(R.id.frag_text_directions);
        textDirectionsFragment.setAdapter(new ArrayList<MapDirectionStep>());

        if (topBarIsOpen) {
            showHideTopBar(true);
        }

        showHideDirectionsButton(true);
        hasDirections = false;
        updateDirectionTextViews();
    }


    /**
     * @param shouldHide
     */
    private void showHideTopBar(boolean shouldHide) {
        int start = shouldHide ? topBarHeightOpen : topBarHeightClosed;
        int end = shouldHide ? topBarHeightClosed : topBarHeightOpen;

        ValueAnimator va = ValueAnimator.ofInt(start, end);
        va.setDuration(200);
        va.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            public void onAnimationUpdate(ValueAnimator animation) {
                Integer value = (Integer) animation.getAnimatedValue();
                FrameLayout fl = (FrameLayout) findViewById(R.id.fl_directions_action_container);
                fl.getLayoutParams().height = value.intValue();
                fl.requestLayout();
            }
        });
        va.start();

        topBarIsOpen = !shouldHide;
    }

    private void updateFloor(final BaseMapBuildingFloor floor) {
        BaseMapBuilding building = floor.getMapBuilding();
        String buildingName = building.getName() != null ? building.getName() : "";
        int targetTvId = floor instanceof MapOutdoorBuildingFloor ? R.id.tv_outdoor_floors : R.id.tv_indoor_floors;

        ((TextView) findViewById(targetTvId)).setText(String.format(Locale.ENGLISH, "%s%s%s", buildingName, !buildingName.isEmpty() ? " - " : "", floor.getName()));


        this.togglePathForFloor(floor);
    }

    /**
     * Issue #211: mirror iOS AppleVenueMapController dual-apply pattern - any
     * floor change writes to both MapLibre and Google stacks (when both are
     * initialized), so neither can drift across a basemap toggle. Each side is
     * wrapped independently so a failure on one stack cannot take down the other.
     */
    private void applyFloorsToBothStacks(List<MapBuildingFloor> indoor,
                                         List<MapOutdoorBuildingFloor> outdoor) {
        if (mapLibreVenueView != null) {
            try {
                mapLibreVenueView.setActiveIndoorFloors(indoor);
                mapLibreVenueView.setActiveOutdoorFloors(outdoor);
            } catch (Exception e) {
                Log.w(TAG, "applyFloorsToBothStacks: MapLibre failed", e);
            }
        }
        if (googleStackInitialized && googleVenueView != null) {
            try {
                googleVenueView.setActiveIndoorFloors(indoor);
                googleVenueView.setActiveOutdoorFloors(outdoor);
            } catch (Exception e) {
                Log.w(TAG, "applyFloorsToBothStacks: Google failed", e);
            }
        }
    }

    /**
     * Strip any fill-extrusion layers from the underlying MapLibre style so the
     * giant translucent 3D building shapes from the MapTiler streets style
     * (e.g. {@code building-3d}) do not occlude the venue overlay. Venue
     * layers use Fill/Line/Symbol/Raster/Background classes, never extrusion,
     * so removal is safe. No-op for non-native (Google WebView) stacks.
     */
    private void removeBasemap3DBuildingExtrusions(MapView map) {
        if (map == null) return;
        if (!(map.getMapInterface() instanceof NativeInterface)) return;
        NativeInterface ni = (NativeInterface) map.getMapInterface();
        if (ni.getMapboxMap() == null) return;
        Style style = ni.getMapboxMap().getStyle();
        if (style == null) return;
        for (Layer layer : style.getLayers()) {
            if (layer instanceof FillExtrusionLayer) {
                style.removeLayer(layer);
            }
        }
    }

    /**
     * @param floor
     */
    private void togglePathForFloor(BaseMapBuildingFloor floor) {
        Boolean outdoors = floor instanceof MapOutdoorBuildingFloor;
        MapBuildingFloor vmdIndoorFloor = outdoors ? null : (MapBuildingFloor) floor;
        MapOutdoorBuildingFloor vmdOutdoorFloor = outdoors ? (MapOutdoorBuildingFloor) floor : null;


        if (getWalkingPathOverlay() != null && getWalkingPathOverlay().getWaypath() != null) {
            this.walkingPathOverlay.setCurrentFloor(vmdIndoorFloor);
            this.walkingPathOverlay.setCurrentOutdoorFloor(vmdOutdoorFloor);
            java.util.Map<String, Collection<? extends BaseMapBuildingFloor>> associatedFloors = this.walkingPathOverlay.getActiveFloorsForFloor(vmdIndoorFloor != null ? vmdIndoorFloor : vmdOutdoorFloor);
            List<MapBuildingFloor> indoorFloors = new ArrayList((Collection<MapBuildingFloor>) (associatedFloors.get("indoor")));
            List<MapOutdoorBuildingFloor> outdoorFloors = new ArrayList(((Collection<MapOutdoorBuildingFloor>) associatedFloors.get("outdoor")));
            //DO NOT CONFUSE which floors are shown/active on the map
            //with which floors need to be active for wayfinding
            List<MapOutdoorBuildingFloor> outdoorToApply = outdoorFloors.isEmpty() && vmdOutdoorFloor != null
                    ? Arrays.asList(vmdOutdoorFloor) : outdoorFloors;
            applyFloorsToBothStacks(indoorFloors, outdoorToApply);
            this.walkingPathOverlay.togglePathForFloors(indoorFloors, outdoorFloors);
        } else {
            // Change only the selected floor's building; keep every other building
            // pinned to its ground floor so it renders ground-floor shapes instead
            // of a footprint mass + name label. activeIndoorFloors is the union.
            List<MapBuildingFloor> indoorToApply;
            if (vmdIndoorFloor != null) {
                if (vmdIndoorFloor.getMapBuilding() != null) {
                    activeFloorByBuilding.put(
                            vmdIndoorFloor.getMapBuilding().getId(), vmdIndoorFloor);
                }
                indoorToApply = new ArrayList<>(activeFloorByBuilding.values());
            } else {
                indoorToApply = this.mapView.getActiveIndoorFloors();
            }
            List<MapOutdoorBuildingFloor> outdoorToApply = vmdOutdoorFloor != null
                    ? Arrays.asList(vmdOutdoorFloor) : this.mapView.getActiveOutdoorFloors();
            applyFloorsToBothStacks(indoorToApply, outdoorToApply);
            if (this.walkingPathOverlay != null) {
                this.walkingPathOverlay.togglePathForFloors(this.mapView.getActiveIndoorFloors(), this.mapView.getActiveOutdoorFloors());
            }
        }
        if (this.getWalkingPathOverlay() != null && this.walkingPathOverlay.getDisplayAllWaypoints()) {
            this.walkingPathOverlay.showDebugWaypointsForFloors(this.mapView.getActiveIndoorFloors(), this.mapView.getActiveOutdoorFloors());
        }
    }


    /**
     *
     */
    private void updateDirectionTextViews() {
        if (directionsFragment != null && walkingPathOverlay != null) {
            directionsFragment.updateUI(walkingPathOverlay.getStartingWaypoint(), walkingPathOverlay.getEndingWaypoint());
        }
    }

    // endregion

    // region Interface Methods


    /**
     * This method is called when a user selects a row on the text directions list
     * It will dismiss the list and jump to the location of the desired direction leg
     *
     * @param pathIndex the row that was selected
     */
    @Override
    public void onTextDirectionsFragmentInteraction(int pathIndex) {
        if (walkingPathOverlay == null) return;
        if (pathIndex >= 0 && pathIndex < walkingPathOverlay.getDirections().size()) {
            WaypathSegment segment = walkingPathOverlay.getDirections().get(pathIndex).getSegment();
            onSegmentSelected(segment);
        }
        hideDirections(findViewById(R.id.btn_close_directions));
    }


    /**
     * @param floor the floor that was selected
     */
    @Override
    public void onFloorFragmentInteraction(FloorItem floor) {
        try {
            hideFloorSelector(findViewById(R.id.btn_back));

            BaseMapBuildingFloor thisFloor = floor.getOutdoor() ?
                    mapView.getMap().findOutdoorFloorWithId(floor.getKey()) :
                    mapView.getMap().findFloorWithId(floor.getKey());

            if (thisFloor != null) {// && !thisFloor.equals(mapView.getCurrentFloor())) {
                updateFloor(thisFloor);
                if (walkingPathOverlay != null && walkingPathOverlay.getWaypath() == null) {
                    SizeF maxSize = new SizeF(this.toDP(this.mapView.getWidth()),
                            this.toDP(this.mapView.getHeight()));
                    mapView.fixCameraToFloor(thisFloor, maxSize, 0, false);
                }
            }
        } catch (Exception e) {
            Log.e(TAG, "Error changing floors - " + e);
        }
    }

    @Override
    public void onFloorInfoClickListener(String floorKey) {
        BaseMapBuildingFloor floor = mapView.getMap().findFloorWithId(floorKey);
        if (floor == null) {
            floor = mapView.getMap().findOutdoorFloorWithId(floorKey);
        }
        if (floor != null) {
            isFloorUnitsFragmentVisible = true;
            FloorMapUnitsInfoFragment floorMapUnitsInfoFragment = FloorMapUnitsInfoFragment.newInstance(new ArrayList<>(floor.getMapUnits()));
            getSupportFragmentManager()
                    .beginTransaction()
                    .add(R.id.frag_floor_units, floorMapUnitsInfoFragment)
                    .commit();
            findViewById(R.id.frag_floor_units).setVisibility(View.VISIBLE);
        }
    }

    // endregion

    // region VMD Map Delegate Callbacks

    /**
     * Callback after map is done loading
     *
     * @param vmd a Map object with waypoint data
     */
    @Override
    public void didFinishLoadingMap(Map vmd, CustomMapInfo customMapInfo) {
        try {
            Log.i(TAG, "Successfully loaded Map Object");
            Log.i(TAG, "Available map providers: " + Arrays.toString(vmd.getAvailableMapProviders().toArray()));
            this.vmd = vmd;
            this.customMapInfo = customMapInfo;

            long start = System.currentTimeMillis();
            //Set the floor selector
            this.indoorFloors.clear();
            this.outdoorFloors.clear();
            if (vmd != null) {
                List<BaseMapBuilding> allBuildings = new ArrayList<BaseMapBuilding>();
                allBuildings.addAll(vmd.getBuildings().values());
                allBuildings.addAll(vmd.getOutdoorBuildings().values());
                for (BaseMapBuilding building : allBuildings) {
                    boolean outdoors = building.getId().contains("outdoors");

                    List<FloorItem> floorsForBuilding = new ArrayList<>();
                    for (java.util.Map.Entry<String, BaseMapBuildingFloor> floorEntry : building.getFloors().entrySet()) {
                        BaseMapBuildingFloor floor = floorEntry.getValue();
                        // if( floor.getMapUnits().isEmpty()) continue; //hide empty floors
                        FloorItem f = new FloorItem(floor.getName(), floor.getOrdinal(), floor.getId(), building.getBuildingNumber());
                        f.setWayfindingAvailable(!floor.getWaypoints().isEmpty());
                        f.setOutdoor(outdoors);
                        floorsForBuilding.add(f);
                    }

                    if (!floorsForBuilding.isEmpty()) {
                        BaseFloorItem buildingItem = new BaseFloorItem(building.getName(), building.getBuildingNumber());
                        if (outdoors) {

                            outdoorFloors.add(buildingItem);
                            outdoorFloors.addAll(floorsForBuilding);
                        } else {
                            indoorFloors.add(buildingItem);
                            indoorFloors.addAll(floorsForBuilding);
                        }
                    }
                }
            }
            //Sort the list of building & floor items.
            Collections.sort(indoorFloors, new BaseFloorItemComparator());
            Collections.sort(outdoorFloors, new BaseFloorItemComparator());
            //end of set floor selector
            long end = System.currentTimeMillis();
            System.out.println(String.format("Took %dms to configure floor selector", end - start));

            if (overlayState != null && vmd != null) {
                List<BaseMapBuilding> sortedBuildings = new ArrayList<>(vmd.getBuildings().values());
                Collections.sort(sortedBuildings, (a, b) -> a.getId().compareTo(b.getId()));

                // Deterministic, cross-platform-stable order for ordinal ties:
                // ordinal asc, then name (case-insensitive) asc, then id asc.
                java.util.Comparator<BaseMapBuildingFloor> floorCmp = (x, y) -> {
                    int c = Integer.compare(x.getOrdinal(), y.getOrdinal());
                    if (c != 0) return c;
                    String xn = x.getName() == null ? "" : x.getName();
                    String yn = y.getName() == null ? "" : y.getName();
                    c = String.CASE_INSENSITIVE_ORDER.compare(xn, yn);
                    if (c != 0) return c;
                    String xi = x.getId() == null ? "" : x.getId();
                    String yi = y.getId() == null ? "" : y.getId();
                    return xi.compareTo(yi);
                };

                // Mirror iOS: floor list and switcher only include indoor buildings.
                // Outdoor (venue_outdoors) is excluded from both flatFloors and groups.
                List<BaseMapBuildingFloor> flatFloors = new ArrayList<>();
                List<com.aegir.demo.ui.FloorGroup> groups = new ArrayList<>();
                for (BaseMapBuilding b : sortedBuildings) {
                    List<BaseMapBuildingFloor> bf = new ArrayList<>(b.getFloors().values());
                    Collections.sort(bf, floorCmp);
                    flatFloors.addAll(bf);
                    groups.add(new com.aegir.demo.ui.FloorGroup(b.getId(), b.getName(), bf));
                }
                overlayState.setFloors(flatFloors);
                overlayState.setFloorGroups(groups);
            }

            directionsFragment = new DirectionsFragment();
            getSupportFragmentManager()
                    .beginTransaction()
                    .add(R.id.fl_directions_container, directionsFragment)
                    .commit();
            this.initializeMapView();
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }


    /**
     * Callback if the map fails to load
     *
     * @param e the exception that was raised during load
     */
    @Override
    public void didFailToLoadMapWithError(Exception e) {

        Log.e(TAG, "Failed to create Map", e);
        this.loadingDialog.hide();
        showAlertDialog("Failed to load map.", e.getMessage() != null && !e.getMessage().isEmpty() ? e.getMessage() : "This is usually an indication of invalid map data.", null);
    }

    /**
     * Callback after waypath has been determined
     *
     * @param path the Waypath that leads from the starting point to the ending point
     */
    @Override
    public void didFinishFindingWaypath(Waypath path) {
        Log.d(TAG, String.format(Locale.ENGLISH, "Took %d milliseconds to find the waypath", (System.currentTimeMillis() - performanceStartTime)));
        performanceStartTime = System.currentTimeMillis();
        this.waypath = path;

        //configure any custom options here
        TurnByTurnDirectionOptions options = new TurnByTurnDirectionOptions();
        options.setMaxDistanceForLeg(25);
        options.setMinDistanceForImportantTurns(10);
        this.vmd.createTurnByTurnDirectionsForWaypath(path, this.customMapInfo, options, MapActivity.this);
    }

    /**
     * Callback when wayfinding fails
     *
     * @param e the exception that was raised
     */
    @Override
    public void didFailToFindWaypathWithError(Exception e) {
        hasDirections = false;
        showAlertDialog("No Path Found", "Sorry, but we could not find a path for your destination.", null);
        clearDirections();

        if (overlayState != null) {
            overlayState.setRouteAvailable(false);
            overlayState.setStartWaypoint(null);
            overlayState.setEndWaypoint(null);
        }

        updateUI();
        showHideTopBar(true);
    }

    /**
     * Toggles the wayfinding selection mode triggered by the Compose overlay.
     */
    private void handleToggleWayfinding() {
        if (walkingPathOverlay == null) return;
        boolean active = !overlayState.getWayfindingActive();
        overlayState.setWayfindingActive(active);
        if (active) {
            walkingPathOverlay.setSelectingPoints(true);
        } else {
            walkingPathOverlay.reset();
            clearDirections();
            overlayState.reset();
        }
    }

    /**
     * Triggered by the Compose GO button.
     */
    private void handleWayfindingGo() {
        if (walkingPathOverlay == null) return;
        Waypoint start = walkingPathOverlay.getStartingWaypoint();
        Waypoint end = walkingPathOverlay.getEndingWaypoint();
        if (start == null || end == null) return;
        if (start.equals(end)) {
            showAlertDialog("Invalid Points", "The start and end point cannot be the same.", null);
            return;
        }
        performanceStartTime = System.currentTimeMillis();
        WayfindingOptions options = new WayfindingOptions(true, false);
        mapView.getMap().findWaypathBetweenWaypoints(start, end, options, MapActivity.this, callback);
    }

    /**
     * Triggered by the Compose Clear button.
     */
    private void handleWayfindingClear() {
        if (walkingPathOverlay != null) {
            walkingPathOverlay.reset();
        }
        clearDirections();
        overlayState.reset();
    }
    /**
     * Called after turn by turn directions are done
     *
     * @param turnByTurnDirections the list of directions
     */
    @Override
    public void didFinishCreatingTurnByTurnDirections(List<MapDirectionStep> turnByTurnDirections) {
        Log.d(TAG, String.format(Locale.ENGLISH, "Took %d milliseconds to create directions", (System.currentTimeMillis() - performanceStartTime)));

        performanceStartTime = System.currentTimeMillis();

        Log.i(TAG, String.format(Locale.ENGLISH, "Successfully found Turn by Turn Directions - %s", turnByTurnDirections));
        walkingPathOverlay.setWaypathAndDirections(this.waypath, new MapDirectionStepList(turnByTurnDirections));

        showHideDirectionsButton(false);
        hasDirections = true;

        updateUI();
        showHideTopBar(true);

        TextDirectionsFragment textDirectionsFragment = (TextDirectionsFragment) getSupportFragmentManager().findFragmentById(R.id.frag_text_directions);
        textDirectionsFragment.setAdapter(turnByTurnDirections);

        if (overlayState != null) {
            overlayState.setDirections(turnByTurnDirections);
            overlayState.setRouteAvailable(true);
        }

        WaypathSegment firstSegment = turnByTurnDirections.get(0).getSegment();
        BaseMapBuildingFloor floor = this.vmd.findFloorWithId(firstSegment.getFloorId());
        if (floor == null) {
            floor = this.vmd.findOutdoorFloorWithId(firstSegment.getFloorId());
        }
        updateFloor(floor);
        onSegmentSelected(firstSegment);

        Log.d(TAG, String.format(Locale.ENGLISH, "Took %d milliseconds to update UI and select segment", (System.currentTimeMillis() - performanceStartTime)));
    }

    /**
     * Callback when turn-by-turn fails
     *
     * @param e the exception that was raised
     */
    @Override
    public void didFailToCreateTurnByTurnDirectionsWithError(Exception e) {
        Log.e(TAG, "Failed to find turn by turn directions", e);
        showAlertDialog("Error", "Sorry, but we could not create turn by turn directions for the path.", null);
    }

    /**
     * @param mapUnit
     * @return
     */
    @Override
    public Boolean canSelectUnit(MapUnit mapUnit) {
        return this.walkingPathOverlay == null || this.walkingPathOverlay.getWaypath() == null;
    }

    /**
     * @param mapUnit
     */
    @Override
    public void didSelectUnit(MapUnit mapUnit) {
        LatLng location = mapUnit.getCenterLocation() != null ? mapUnit.getCenterLocation() : mapUnit.getLabelLocation();
        if (this.walkingPathOverlay == null || (!this.walkingPathOverlay.isSelectingPoints() && this.walkingPathOverlay.getWaypath() == null)) {
            Log.i(TAG, "Selected map unit: " + mapUnit);
            String id = mapUnit.getId();
            String unitName =  mapUnit.getName() != null ? mapUnit.getName() : "";
            String category =  mapUnit.getCategory() != null ? mapUnit.getCategory() : "";
            String floorId =  mapUnit.getFloor().getId();

            if (overlayState != null) {
                overlayState.setSelectedUnitInfo(new UnitInfo(id, unitName, category, floorId, null));
            }
        } else if (walkingPathOverlay != null) {
            walkingPathOverlay.didTapUnitAtLocation(mapUnit, location);
            updateDirectionTextViews();
            if (overlayState != null) {
                overlayState.setStartWaypoint(walkingPathOverlay.getStartingWaypoint());
                overlayState.setEndWaypoint(walkingPathOverlay.getEndingWaypoint());
            }
        }
    }

    @Override
    public Boolean canSelectBuilding(BaseMapBuilding building) {
       return true;
    }

    /**
     *
     * @param building The building that was selected, or null.
     */
    @Override
    public void didSelectBuilding(BaseMapBuilding building) {
        Log.i(TAG, String.format(Locale.ENGLISH, "Selected building: %s", building != null ? building.getName() : "NULL"));
        if (building == null) {
            return;
        }
        if( this.selectedBuilding != null && building.getId().equals(this.selectedBuilding.getId())) {
            //remove the style for the selected building,
            //because A) same building selected again, remove style
            // or B) new building selected, remove style from this one
            this.mapView.removeStyleForBuilding(this.selectedBuilding);
        }
        if (this.selectedBuilding == null || !building.getId().equals(this.selectedBuilding.getId())) {
            VenueLayerStyle style = VenueLayerStyle.createWithFillColorValue(Color.parseColor("#EAF6FF"));
            style.setOutlineColor(Color.parseColor("#3B6E6D"));
            style.setOutlineMinWidth(3f);
            style.setFontStrokeColor(style.getOutlineColor());
            style.setFontColor(style.getFillColor());
            this.mapView.setStyleForBuilding(style, building);
            this.selectedBuilding = building;
        } else {
            this.selectedBuilding = null;
        }
    }

    /**
     * This code shows an example of how one might apply
     * custom style code to a single map element
     *
     * @since 1.3
     */
    public void changeIcons() {
        List<String> keywords = Arrays.asList("Elevator", "Men", "Women", "Stairs");
        Bitmap elevatorImg = BitmapFactory.decodeResource(getResources(), R.drawable.icon_elevator_new);
        this.mapView.setImageForKey(elevatorImg, "new_elevator_icon");

        Bitmap restroomImg = BitmapFactory.decodeResource(getResources(), R.drawable.icon_restroom_new);
        this.mapView.setImageForKey(restroomImg, "new_restroom_icon");

        Bitmap stairwellImg = BitmapFactory.decodeResource(getResources(), R.drawable.icon_stairwell_new);
        this.mapView.setImageForKey(stairwellImg, "new_stairwell_icon");

        for (MapBuilding building : this.vmd.getBuildings().values()) {
            for (BaseMapBuildingFloor floor : building.getFloors().values()) {
                for (MapUnit unit : floor.getMapUnits()) {
                    // StringUtils.indexOfAny(unit.getName(), keywords);

                    boolean found = false;
                    for (String s : keywords) {
                        String name = unit.getName();
                        if (name.contains(s)) {
                            found = true;
                            break;
                        }
                    }
                    if (found) {
                        VenueLayerStyle style = new VenueLayerStyle();
                        if (unit.getType() == MapUnitType.ELEVATOR) {
                            style.setIconName("new_elevator_icon");
                            this.mapView.setStyleForMapUnit(style, unit);
                        }
                        else if (unit.getType() == MapUnitType.RESTROOM) {
                            style.setIconName("new_restroom_icon");
                            this.mapView.setStyleForMapUnit(style, unit);

                        } else if (unit.getType() == MapUnitType.STAIRS) {
                            style.setIconName("new_stairwell_icon");
                            this.mapView.setStyleForMapUnit(style, unit);
                        }
                    }
                }
            }
        }
    }

    @Override
    public void didTapAtCoordinate(LatLng location) {

        List<BaseMapBuildingFloor> floors = new ArrayList<>(mapView.getActiveIndoorFloors().size() + mapView.getActiveOutdoorFloors().size());
        floors.addAll(mapView.getActiveIndoorFloors());
        floors.addAll(mapView.getActiveOutdoorFloors());
        MapUnit selectedUnit = VMDUtil.findMapUnitContainingLocation(location, floors);
        if (selectedUnit != null && this.canSelectUnit(selectedUnit)) {
            this.didSelectUnit(selectedUnit);
        }
    }

    /**
     * @param floorChangePointAnnotation
     */
    @Override
    public void didSelectFloorChangeAnnotation(FloorChangePointAnnotation floorChangePointAnnotation) {
        onSegmentSelected(floorChangePointAnnotation.getSegment());
    }

    /**
     * @param mapAnnotation
     */
    @Override
    public void didSelectAnnotation(MapAnnotation mapAnnotation) {
        Log.i(TAG, "Selected annotation : " + mapAnnotation.getId());
       // this.mapView.removeMarker(mapAnnotation);
//        String unitId = mapAnnotation.getId() != null ? mapAnnotation.getId() : "";
//        MapUnit unit = null;
//        for (MapBuildingFloor floor : this.mapView.getActiveIndoorFloors()) {
//            unit = floor.getMapUnit(unitId);
//            if (unit != null) {
//                break;
//            }
//        }
//        if (unit == null) {
//            for (MapOutdoorBuildingFloor floor : this.mapView.getActiveOutdoorFloors()) {
//                unit = floor.getMapUnit(unitId);
//                if (unit != null) {
//                    break;
//                }
//            }
//        }
//        if (unit != null) {
//           // this.mapView.removeStyleForMapUnit(unit);
//            showAlertDialog("Selected annotation", unit.getName() != null ? unit.getName() : mapAnnotation.getId(), null);
//        }
    }

    /**
     * Callback when map annotation's popup is opened
     * @param annotation
     */
    @Override
    public void didOpenCalloutViewForAnnotation(MapAnnotation annotation) {
        Log.i(TAG, "Opened callout view for annotation : " + annotation.getId());
    }

    /**
     * Frames the initial camera to the union of every real building's outline
     * (excluding the venue_outdoors pseudo-building), falling back to their
     * ordinal-0 floor outlines. Returns false when no such geometry exists or
     * the view is not yet sized, so callers fall back to the VMD centerPoint +
     * fixed zoom. Mirrors the iOS demo's groundFloorFitCamera.
     */
    private boolean fitCameraToGroundFloors(MapView mapView) {
        if (this.vmd == null) {
            return false;
        }
        // getBoundsZoomLevel expects density-independent pixels (matching iOS
        // points and the SDK's getMapWidth/getMapHeight); raw pixels overzoom
        // by ~log2(density).
        int width = toDP(mapView.getWidth());
        int height = toDP(mapView.getHeight());
        if (width <= 0 || height <= 0) {
            return false;
        }
        List<List<LatLng>> rings = new ArrayList<>();
        for (MapBuilding building : this.vmd.getBuildings().values()) {
            if ("venue_outdoors".equals(building.getId())) {
                continue;
            }
            if (building.getCoordinates() != null && !building.getCoordinates().isEmpty()) {
                rings.addAll(building.getCoordinates());
            }
        }
        if (rings.isEmpty()) {
            // Fallback: ordinal-0 floor outlines of the non-outdoor buildings.
            for (MapBuilding building : this.vmd.getBuildings().values()) {
                if ("venue_outdoors".equals(building.getId())) {
                    continue;
                }
                for (BaseMapBuildingFloor floor : building.getFloors().values()) {
                    if (floor.getOrdinal() == 0
                            && floor.getCoordinates() != null
                            && !floor.getCoordinates().isEmpty()) {
                        rings.addAll(floor.getCoordinates());
                    }
                }
            }
        }
        if (rings.isEmpty()) {
            return false;
        }
        LatLngBounds bounds = new LatLngBounds(rings);
        LatLng center = new LatLng(
                (bounds.northeast.latitude + bounds.southwest.latitude) / 2.0,
                (bounds.northeast.longitude + bounds.southwest.longitude) / 2.0);
        float zoom = WalkingPathOverlayUtil.getBoundsZoomLevel(
                bounds, width, height, mapView.getMaxZoom());
        zoom = Math.max(0f, zoom - CAMERA_FIT_PADDING);
        Log.i(TAG, String.format(
                "Fitting camera to %d building-outline ring(s): center (%f,%f) zoom %f",
                rings.size(), center.latitude, center.longitude, zoom));
        mapView.setMapPosition(center, 0f, zoom);
        return true;
    }

    /**
     * @param mapView
     */
    public void willStartLoadingMapView(MapView mapView) {
        if (this.mapViewLoadError || mapView == null) {
            return;
        }

        mapView.setMinZoom(AppConstants.MAP_MIN_ZOOM);
        mapView.setMaxZoom(AppConstants.MAP_MAX_ZOOM);

        // If we're initializing a second stack post-toggle, prefer the captured camera over VMD initial.
        if (hasCapturedCamera && lastCameraTarget != null) {
            Log.i(TAG, String.format("Restoring captured camera (%f,%f) zoom=%f bearing=%f",
                    lastCameraTarget.latitude, lastCameraTarget.longitude, lastCameraZoom, lastCameraBearing));
            mapView.setMapPosition(lastCameraTarget, lastCameraBearing, lastCameraZoom);
        } else if (!fitCameraToGroundFloors(mapView)) {
            LatLng center = this.vmd.getCenterPoint();
            // Mirror iOS AppleVenueMapController: pin every initial load to a
            // north-up orientation and a fixed zoom regardless of the authored
            // venue XML placement values.
            float bearing = 0f;
            float zoom = 19f;
            // #174: Occasionally the centerpoint is not set and needs to be guarded.
            if (center != null) {
                Log.i(TAG, String.format("Initializing map position to (%f,%f), zoom: %f, heading: %f", center.latitude, center.longitude, zoom, bearing));
                mapView.setMapPosition(center, bearing, zoom - 1);
            } else {
                Log.w(TAG, "Center point is null, skipping map position initialization");
            }
        }

        long start = System.currentTimeMillis();
        long end;
        List<MapBuildingFloor> activeIndoorFloors;

        //Show the lowest-ordinal floor in every building (mirrors iOS, which loads
        //the lowest ordinal so basement-only venues like OSAPYPY default to "Basement 1"
        //rather than skipping to "Floor 1"). Every non-selected building stays
        //pinned to its ground floor so it renders ground-floor shapes instead of a
        //footprint mass + name label. Guard so a secondary-stack re-init after a
        //basemap toggle does not overwrite user selections.
        if (activeFloorByBuilding.isEmpty()) {
            for (MapBuilding building : vmd.getBuildings().values()) {
                BaseMapBuildingFloor lowestForBuilding = null;
                for (BaseMapBuildingFloor floor : building.getFloors().values()) {
                    if (lowestForBuilding == null || floor.getOrdinal() < lowestForBuilding.getOrdinal()) {
                        lowestForBuilding = floor;
                    }
                }
                if (lowestForBuilding instanceof MapBuildingFloor) {
                    activeFloorByBuilding.put(building.getId(), (MapBuildingFloor) lowestForBuilding);
                }
            }
        }
        activeIndoorFloors = new ArrayList<>(activeFloorByBuilding.values());
        //default outdoor floor
        MapOutdoorBuildingFloor outdoorFloor = vmd.findOutdoorFloorWithId("floor_vo_1");
        List<MapOutdoorBuildingFloor> activeOutdoorFloors = new ArrayList<>();
        if (outdoorFloor != null) {
            activeOutdoorFloors.add(outdoorFloor);
        }

        // Issue #211: when re-initializing a secondary stack post-toggle (e.g. lazy
        // Google init after the user already chose a floor on MapLibre), inherit
        // the user-selected floor from overlayState instead of resetting to the
        // lowest-ordinal-per-building defaults computed above. Mirrors iOS
        // AppleVenueMapController, which seeds activeIndoorFloors from
        // mapLibreVenueView.activeIndoorFloors when the Apple overlay is created.
        if (overlayState != null && overlayState.getCurrentFloor() != null) {
            BaseMapBuildingFloor preferred = overlayState.getCurrentFloor();
            if (preferred instanceof MapOutdoorBuildingFloor) {
                activeOutdoorFloors.clear();
                activeOutdoorFloors.add((MapOutdoorBuildingFloor) preferred);
            } else if (preferred instanceof MapBuildingFloor) {
                // Keep other buildings pinned to their ground floor; only the
                // preferred building's floor is the user selection. Rebuild the
                // union so the secondary stack matches the primary.
                MapBuildingFloor preferredIndoor = (MapBuildingFloor) preferred;
                if (preferredIndoor.getMapBuilding() != null) {
                    activeFloorByBuilding.put(
                            preferredIndoor.getMapBuilding().getId(), preferredIndoor);
                }
                activeIndoorFloors = new ArrayList<>(activeFloorByBuilding.values());
            }
        }

        if (overlayState != null && overlayState.getCurrentFloor() == null) {
            if (!activeIndoorFloors.isEmpty()) {
                overlayState.setCurrentFloor(activeIndoorFloors.get(0));
            } else if (outdoorFloor != null) {
                overlayState.setCurrentFloor(outdoorFloor);
            }
        }

        //Step 4:
        //Provide all necessary data for your map to work.
        VMMapViewLoadConfig mapViewConfig = new VMMapViewLoadConfig();
        String baseUrl = (extractedVenuePath != null)
                ? String.format(Locale.ENGLISH, "file://%s", extractedVenuePath)
                : String.format(Locale.ENGLISH, "asset://venue_map_%s", mVenue);
        if (!isUsingLocalAssets) {
            baseUrl = String.format(Locale.ENGLISH, "%s/%s/venue_map_%s", mCloudfrontUrl, mVenue, mVenue);
            if (this.vmd.getProcessingMode() == VMProcessingMode.LEGACY) {
                mapViewConfig.setLegacyVenueBaseURL(mCloudfrontUrl);
            }
        }
        mapViewConfig.setVenueBaseURL(baseUrl, getVMD().getVersion(), defaultMapProvider);
        mapViewConfig.setMapSpritesURL("asset://icons/icons");
        mapViewConfig.setUseLegacyBuildingTileUrl(false);
        if (this.vmd.getProcessingMode() == VMProcessingMode.DEFAULT && AppConstants.VENUE_ID.equals(DEFAULT_VENUE_ID)) {
            String baseURL = (extractedVenuePath != null)
                    ? String.format(Locale.ENGLISH, "file://%s", extractedVenuePath)
                    : String.format(Locale.ENGLISH, "asset://venue_map_%s", mVenue);
            mapViewConfig.setVenueBaseURL(baseURL, getVMD().getVersion(), defaultMapProvider);
            //override default vector-tiles/vector-common urls
            mapViewConfig.setVectorTilesetURLTemplate(mapViewConfig.getVenueBaseURL() + "/vector-tiles");
            mapViewConfig.setVectorCommonTilesetURLTemplate(mapViewConfig.getVenueBaseURL() + "/vector-common");
        }

        mapViewConfig.setMinZoom(AppConstants.MAP_MIN_ZOOM);
        mapViewConfig.setMaxZoom(AppConstants.MAP_MAX_ZOOM);

        end = System.currentTimeMillis();
        System.out.println(String.format("Took %dms to pre-configure mapview", end - start));
        start = System.currentTimeMillis();

        mapView.setupMap(vmd, this.venueStyle,
                mVenue, activeIndoorFloors, activeOutdoorFloors,
                mapViewConfig
        );
        end = System.currentTimeMillis();
        System.out.println(String.format("Took %dms to call set up mapview", end - start));
    }

    /**
     * @param map
     */

    @Override
    public void didFinishLoadingMapView(MapView map) {
        Log.i(TAG, String.format("(%s).didFinishLoadingMapView: Finished loading VMMapView for venue '%s'", this.getLocalClassName(), map.getVenueId()));
        this.loadingDialog.hide();
        map.setBackgroundAlpha(0f);
        removeBasemap3DBuildingExtrusions(map);

//        for (MapBuilding building : this.vmd.getBuildings().values()) {
//            for (BaseMapBuildingFloor floor : building.getFloors().values()) {
//                this.applyCustomStylesToFloor(floor);
//                this.addTestAnnotationsToFloor(floor);
//            }
//        }
        // changeIcons();
    }

    /**
     * @param map
     * @param ex
     */
    @Override
    public void didFailToLoadMapView(MapView map, Exception ex) {
        String errorMsg = ex.getMessage() != null ? ex.getMessage() : "Unknown error";

        // Enhanced logging for debugging HTTP errors
        Log.e(TAG, "========== MAP LOAD FAILURE ==========");
        Log.e(TAG, "Failed to load map - " + errorMsg);
        Log.e(TAG, "Exception type: " + ex.getClass().getName());

        // Analyze error message for HTTP status codes
        if (errorMsg.contains("403") || errorMsg.toLowerCase().contains("forbidden")) {
            Log.e(TAG, "HTTP 403 FORBIDDEN ERROR DETECTED");
            Log.e(TAG, "Evidence: MapTiler/Cloudflare is blocking the request");
            Log.e(TAG, "Likely cause: Compression headers (Accept-Encoding: gzip) triggering bot protection");
            Log.e(TAG, "User-Agent: MapLibre Native/11.6.1 Android SDK");
        } else if (errorMsg.contains("401") || errorMsg.toLowerCase().contains("unauthorized")) {
            Log.e(TAG, "HTTP 401 UNAUTHORIZED ERROR DETECTED");
            Log.e(TAG, "Evidence: API key may be invalid or expired");
        } else if (errorMsg.contains("404") || errorMsg.toLowerCase().contains("not found")) {
            Log.e(TAG, "HTTP 404 NOT FOUND ERROR DETECTED");
            Log.e(TAG, "Evidence: Style URL may be incorrect");
        } else if (errorMsg.matches(".*\\b4[0-9]{2}\\b.*")) {
            Log.e(TAG, "HTTP 4XX CLIENT ERROR DETECTED");
            Log.e(TAG, "Evidence: Client-side request issue");
        }

        Log.e(TAG, "Stack trace:");
        ex.printStackTrace();
        Log.e(TAG, "======================================");

        this.mapViewLoadError = true;
        this.loadingDialog.hide();

        String userMessage = errorMsg;
        if (errorMsg.contains("403") || errorMsg.toLowerCase().contains("forbidden")) {
            userMessage = "HTTP 403 Forbidden: MapTiler is blocking requests. This appears to be a Cloudflare bot protection issue with compression headers.";
        }

        showAlertDialog("Failed to load map view.", userMessage, null);
    }

    private void showAlertDialog(String title, String message, DialogInterface.OnClickListener listener) {
        new AlertDialog.Builder(MapActivity.this)
                .setTitle(title)
                .setMessage(message)
                .setPositiveButton(android.R.string.ok, listener)
                .show();
    }

    /**
     * @param annotation
     * @return
     */
    public Bitmap imageForLandmarkAnnotation(LandmarkAnnotation annotation) {
        return this.imageForLandmark(annotation.getWaypoint().getFirstNamesake());
    }

    @Override
    public InfoWindow calloutViewForPointAnnotation(PointAnnotation pointAnnotation) {
        return null;
    }

    @Override
    public boolean canShowCalloutViewForPointAnnotation(PointAnnotation pointAnnotation) {
        return false;
    }

    @Override
    public void didTapOnMap(LatLng coords) {
        Log.d(TAG, "Did tap on MapView");
    }
    //endregion

    //region Walking Path Overlay Delegate Methods

    /**
     * @param waypoint
     * @return
     */
    @Override
    public Bitmap imageForLandmark(Waypoint waypoint) {
        String assetPath = AppConstants.VENUE_ID + "/landmarks/" + waypoint.getId() + "@2x.png";
        return getBitmapFromAsset(this, assetPath);
    }

    /**
     * @param annotation
     * @return
     */
    @Override
    public Bitmap imageForPointAnnotation(PointAnnotation annotation) {

        if (VenueWayfindingConstants.kWayfindingStartPointIconIdentifier.equals(annotation.getId())) {
            return BitmapFactory.decodeResource(getResources(), R.drawable.icon_start_point);
        } else if (VenueWayfindingConstants.kWayfindingEndPointIconIdentifier.equals(annotation.getId())) {
            return BitmapFactory.decodeResource(getResources(), R.drawable.icon_end_point);
        }

        return null;
    }

    /**
     * @param annotation
     * @return
     */
    @Override
    public Bitmap imageForFloorChangeAnnotation(FloorChangePointAnnotation annotation) {
        return null; //the SDK will use default images when we return null
    }

    // endregion

    //region Accessors

    /**
     * @return
     */
    public BaseWalkingPathOverlay getWalkingPathOverlay() {
        return walkingPathOverlay;
    }

    /**
     * @return
     */
    public MapView getMapView() {
        return mapView;
    }


    public Map getVMD() {
        return this.vmd;
    }

    @Override
    public void onBackPressed() {
        if (isFloorSelectorFragmentVisible) {
            hideFloorSelector(null);
        } else {
            goBack(null);
        }
    }

    // region MapView lifecycle forwarding (dual-stack)

    @Override
    protected void onResume() {
        super.onResume();
        if (mapLibreVenueView != null && currentMode == BasemapMode.MAPLIBRE) {
            try { mapLibreVenueView.onResume(); } catch (Exception e) { Log.w(TAG, "mapLibreVenueView.onResume failed", e); }
        }
        if (googleVenueView != null && currentMode == BasemapMode.GOOGLE_MAPS) {
            try { googleVenueView.onResume(); } catch (Exception e) { Log.w(TAG, "googleVenueView.onResume failed", e); }
        }
    }

    @Override
    protected void onPause() {
        if (mapLibreVenueView != null) {
            try { mapLibreVenueView.onPause(); } catch (Exception e) { Log.w(TAG, "mapLibreVenueView.onPause failed", e); }
        }
        if (googleVenueView != null) {
            try { googleVenueView.onPause(); } catch (Exception e) { Log.w(TAG, "googleVenueView.onPause failed", e); }
        }
        super.onPause();
    }

    @Override
    protected void onStart() {
        super.onStart();
        if (mapLibreVenueView != null) {
            try { mapLibreVenueView.onStart(); } catch (Exception e) { Log.w(TAG, "mapLibreVenueView.onStart failed", e); }
        }
        if (googleVenueView != null) {
            try { googleVenueView.onStart(); } catch (Exception e) { Log.w(TAG, "googleVenueView.onStart failed", e); }
        }
    }

    @Override
    protected void onStop() {
        if (mapLibreVenueView != null) {
            try { mapLibreVenueView.onStop(); } catch (Exception e) { Log.w(TAG, "mapLibreVenueView.onStop failed", e); }
        }
        if (googleVenueView != null) {
            try { googleVenueView.onStop(); } catch (Exception e) { Log.w(TAG, "googleVenueView.onStop failed", e); }
        }
        super.onStop();
    }

    @Override
    protected void onSaveInstanceState(@androidx.annotation.NonNull Bundle outState) {
        super.onSaveInstanceState(outState);
        if (mapLibreVenueView != null) {
            try { mapLibreVenueView.onSaveInstanceState(outState); } catch (Exception e) { Log.w(TAG, "mapLibreVenueView.onSaveInstanceState failed", e); }
        }
        if (googleVenueView != null) {
            try { googleVenueView.onSaveInstanceState(outState); } catch (Exception e) { Log.w(TAG, "googleVenueView.onSaveInstanceState failed", e); }
        }
    }

    @Override
    protected void onDestroy() {
        if (mapLibreVenueView != null) {
            try { mapLibreVenueView.onDestroy(); } catch (Exception e) { Log.w(TAG, "mapLibreVenueView.onDestroy failed", e); }
        }
        if (googleVenueView != null) {
            try { googleVenueView.onDestroy(); } catch (Exception e) { Log.w(TAG, "googleVenueView.onDestroy failed", e); }
        }
        super.onDestroy();
    }

    // endregion
}
