import { throttle } from 'es-toolkit';

import { dispatch as d3_dispatch } from 'd3-dispatch';
import { json as d3_json } from 'd3-fetch';
import { osmAuth } from 'osm-auth';
import RBush from 'rbush';

import { JXON } from '../util/jxon';
import { geoExtent, geoRawMercator, geoVecAdd, geoZoomToScale } from '../geo';
import { osmIdManager, osmNode, osmNote, osmRelation, osmWay } from '../osm';
import { utilArrayChunk, utilArrayGroupBy, utilArrayUniq, utilObjectOmit, utilRebind, utilTiler, utilQsString } from '../util';
import { localizer } from '../core/localizer.js';
import { utilGzip } from '../util/util';
import { ApiError } from '../util/error';
import { osmApiConnections } from '../../config/id.js';


var tiler = utilTiler();
var dispatch = d3_dispatch('apiStatusChange', 'authLoading', 'authDone', 'change', 'loading', 'loaded', 'loadedNotes');

var urlroot = osmApiConnections[0].url;
var apiUrlroot = osmApiConnections[0].apiUrl || urlroot;
var redirectPath = window.location.origin + window.location.pathname;
var oauth = new osmAuth({
    url: urlroot,
    apiUrl: apiUrlroot,
    client_id: osmApiConnections[0].client_id,
    scope: 'read_prefs write_prefs write_api read_gpx write_notes',
    redirect_uri: redirectPath + 'land.html',
    loading: authLoading,
    done: authDone
});
var _apiConnections = osmApiConnections;

// hardcode default block of Google Maps
var _imageryBlocklists = [/.*\.google(apis)?\..*\/(vt|kh)[\?\/].*([xyz]=.*){3}.*/];
var _tileCache = { toLoad: {}, loaded: {}, inflight: {}, seen: {}, rtree: new RBush() };
var _noteCache = { toLoad: {}, loaded: {}, inflight: {}, inflightPost: {}, note: {}, closed: {}, rtree: new RBush() };
var _userCache = { toLoad: {}, user: {} };
var _cachedApiStatus;
var _changeset = {};

var _deferred = new Set();
var _connectionID = 1;
var _tileZoom = 16;
var _noteZoom = 12;
var _rateLimitError;
var _userChangesets;
var _userDetails;
var _off;

// set a default but also load this from the API status
var _maxWayNodes = 2000;
let _maxChangesetElements = 10_000;


function authLoading() {
    dispatch.call('authLoading');
}


function authDone() {
    dispatch.call('authDone');
}


function abortRequest(controllerOrXHR) {
    if (controllerOrXHR) {
        controllerOrXHR.abort();
    }
}


function hasInflightRequests(cache) {
    return Object.keys(cache.inflight).length;
}


function abortUnwantedRequests(cache, visibleTiles) {
    Object.keys(cache.inflight).forEach(function(k) {
        if (cache.toLoad[k]) return;
        if (visibleTiles.find(function(tile) { return k === tile.id; })) return;

        abortRequest(cache.inflight[k]);
        delete cache.inflight[k];
    });
}

function getNodesJSON(obj) {
    var elems = obj.nodes;
    var nodes = new Array(elems.length);
    for (var i = 0, l = elems.length; i < l; i++) {
        nodes[i] = 'n' + elems[i];
    }
    return nodes;
}

function getMembersJSON(obj) {
    var elems = obj.members;
    var members = new Array(elems.length);
    for (var i = 0, l = elems.length; i < l; i++) {
        var attrs = elems[i];
        members[i] = {
            id: attrs.type[0] + attrs.ref,
            type: attrs.type,
            role: attrs.role
        };
    }
    return members;
}

function encodeNoteRtree(note) {
    return {
        minX: note.loc[0],
        minY: note.loc[1],
        maxX: note.loc[0],
        maxY: note.loc[1],
        data: note
    };
}


var jsonparsers = {

    node: function nodeData(obj, uid) {
        return new osmNode({
            id:  uid,
            visible: typeof obj.visible === 'boolean' ? obj.visible : true,
            version: obj.version && obj.version.toString(),
            changeset: obj.changeset && obj.changeset.toString(),
            timestamp: obj.timestamp,
            user: obj.user,
            uid: obj.uid && obj.uid.toString(),
            loc: [Number(obj.lon), Number(obj.lat)],
            tags: obj.tags
        });
    },

    way: function wayData(obj, uid) {
        return new osmWay({
            id:  uid,
            visible: typeof obj.visible === 'boolean' ? obj.visible : true,
            version: obj.version && obj.version.toString(),
            changeset: obj.changeset && obj.changeset.toString(),
            timestamp: obj.timestamp,
            user: obj.user,
            uid: obj.uid && obj.uid.toString(),
            tags: obj.tags,
            nodes: getNodesJSON(obj)
        });
    },

    relation: function relationData(obj, uid) {
        return new osmRelation({
            id:  uid,
            visible: typeof obj.visible === 'boolean' ? obj.visible : true,
            version: obj.version && obj.version.toString(),
            changeset: obj.changeset && obj.changeset.toString(),
            timestamp: obj.timestamp,
            user: obj.user,
            uid: obj.uid && obj.uid.toString(),
            tags: obj.tags,
            members: getMembersJSON(obj)
        });
    },

    user: function parseUser(obj, uid) {
        return {
            id: uid,
            display_name: obj.display_name,
            account_created: obj.account_created,
            image_url: obj.img && obj.img.href,
            changesets_count: obj.changesets && obj.changesets.count && obj.changesets.count.toString() || '0',
            active_blocks: obj.blocks && obj.blocks.received && obj.blocks.received.active && obj.blocks.received.active.toString() || '0'
        };
    }
};

function parseJSON(payload, callback, options) {
    options = Object.assign({ skipSeen: true }, options);
    if (!payload)  {
        return callback(new ApiError('No JSON', -1));
    }

    var json = payload;
    if (typeof json !== 'object') json = JSON.parse(payload);

    if (!json.elements) return callback(new ApiError('No JSON', -1));

    if (typeof json.elements.at(-1)?.error === 'string') {
        const errorMessage = payload.elements.at(-1).error;
        return callback(new ApiError(errorMessage, -1));
    }

    var children = json.elements;

    var handle = window.requestIdleCallback(function() {
        _deferred.delete(handle);
        var results = [];
        var result;
        for (var i = 0; i < children.length; i++) {
            result = parseChild(children[i]);
            if (result) results.push(result);
        }
        callback(null, results);
    });
    _deferred.add(handle);

    function parseChild(child) {
        var parser = jsonparsers[child.type];
        if (!parser) return null;

        var uid;

        uid = osmIdManager.fromOSM(child.type, child.id);
        if (options.skipSeen) {
            if (_tileCache.seen[uid]) return null;  // avoid reparsing a "seen" entity
            _tileCache.seen[uid] = true;
        }

        return parser(child, uid);
    }
}

function parseUserJSON(payload, callback, options) {
    options = Object.assign({ skipSeen: true }, options);
    if (!payload)  {
        return callback(new ApiError('No JSON', -1));
    }

    var json = payload;
    if (typeof json !== 'object') json = JSON.parse(payload);

    if (!json.users && !json.user) return callback(new ApiError('No JSON', -1));

    var objs = json.users || [json];

    var handle = window.requestIdleCallback(function() {
        _deferred.delete(handle);
        var results = [];
        var result;
        for (var i = 0; i < objs.length; i++) {
            result = parseObj(objs[i]);
            if (result) results.push(result);
        }
        callback(null, results);
    });
    _deferred.add(handle);

    function parseObj(obj) {
        var uid = obj.user.id && obj.user.id.toString();
        if (options.skipSeen && _userCache.user[uid]) {
            delete _userCache.toLoad[uid];
            return null;
        }
        var user = jsonparsers.user(obj.user, uid);
        _userCache.user[uid] = user;
        delete _userCache.toLoad[uid];
        return user;
    }
}

function parseNoteJSON(payload, callback, _options) {
    const options = { skipSeen: true, ..._options };
    if (!payload)  {
        return callback(new ApiError('No JSON', -1));
    }

    const features = payload.type === 'FeatureCollection' ? payload.features : [payload];

    const notes = features.map(feature => {
        const uid = feature.properties.id;
        if (options.skipSeen) {
            if (_tileCache.seen[uid]) return null;  // avoid reparsing a "seen" entity
            _tileCache.seen[uid] = true;
        }

        const props = {
            ...feature.properties,
            loc: feature.geometry.coordinates,
        };

        // if notes are coincident, move them apart slightly
        if (!_noteCache.note[uid]) {
            let coincident = false;
            const epsilon = 0.00001;
            do {
                if (coincident) {
                    props.loc = geoVecAdd(props.loc, [epsilon, epsilon]);
                }
                const bbox = geoExtent(props.loc).bbox();
                coincident = _noteCache.rtree.search(bbox).length;
            } while (coincident);
        } else {
            // we already saw this note: don't change its location again
            props.loc = _noteCache.note[uid].loc;
        }

        var note = new osmNote(props);
        var item = encodeNoteRtree(note);
        _noteCache.note[note.id] = note;
        updateRtree(item, item);

        return note;
    });
    callback(undefined, notes);
}

// replace or remove note from rtree
function updateRtree(item, replaceItem) {
    _noteCache.rtree.remove(item, function isEql(a, b) { return a.data.id === b.data.id; });

    if (replaceItem) {
        _noteCache.rtree.insert(replaceItem);
    }
}


function wrapcb(thisArg, callback, cid) {
    return function(err, result) {
        if (err) {
            return callback.call(thisArg, err);

        } else if (thisArg.getConnectionId() !== cid) {
            return callback.call(thisArg, new ApiError('Connection Switched', -1));

        } else {
            return callback.call(thisArg, err, result);
        }
    };
}


export default {

    init: function() {
        utilRebind(this, dispatch, 'on');
    },


    reset: function() {
        Array.from(_deferred).forEach(function(handle) {
            window.cancelIdleCallback(handle);
            _deferred.delete(handle);
        });

        _connectionID++;
        _userChangesets = undefined;
        _userDetails = undefined;
        _rateLimitError = undefined;

        Object.values(_tileCache.inflight).forEach(abortRequest);
        Object.values(_noteCache.inflight).forEach(abortRequest);
        Object.values(_noteCache.inflightPost).forEach(abortRequest);
        if (_changeset.inflight) abortRequest(_changeset.inflight);

        _tileCache = { toLoad: {}, loaded: {}, inflight: {}, seen: {}, rtree: new RBush() };
        _noteCache = { toLoad: {}, loaded: {}, inflight: {}, inflightPost: {}, note: {}, closed: {}, rtree: new RBush() };
        _userCache = { toLoad: {}, user: {} };
        _cachedApiStatus = undefined;
        _changeset = {};

        return this;
    },


    getConnectionId: function() {
        return _connectionID;
    },


    getUrlRoot: function() {
        return urlroot;
    },


    getApiUrlRoot: function() {
        return apiUrlroot;
    },


    changesetURL: function(changesetID) {
        return urlroot + '/changeset/' + changesetID;
    },


    changesetsURL: function(center, zoom) {
        var precision = Math.max(0, Math.ceil(Math.log(zoom) / Math.LN2));
        return urlroot + '/history#map=' +
            Math.floor(zoom) + '/' +
            center[1].toFixed(precision) + '/' +
            center[0].toFixed(precision);
    },


    entityURL: function(entity) {
        return urlroot + '/' + entity.type + '/' + entity.osmId();
    },


    historyURL: function(entity) {
        return urlroot + '/' + entity.type + '/' + entity.osmId() + '/history';
    },


    userURL: function(username) {
        return urlroot + '/user/' + encodeURIComponent(username);
    },


    noteURL: function(note) {
        return urlroot + '/note/' + note.id;
    },


    noteReportURL: function(note) {
        return urlroot + '/reports/new?reportable_type=Note&reportable_id=' + note.id;
    },


    // Generic method to load data from the OSM API
    // Can handle either auth or unauth calls.
    loadFromAPI: function(path, callback, options) {
        options = Object.assign({ skipSeen: true }, options);
        var that = this;
        var cid = _connectionID;

        function done(err, payloadString) {
            if (that.getConnectionId() !== cid) {
                if (callback) callback(new ApiError('Connection Switched', -1));
                return;
            }

            if ((err && _cachedApiStatus === 'online') ||
                (!err && _cachedApiStatus !== 'online')) {
                // If the response's error state doesn't match the status,
                // it's likely we lost or gained the connection so reload the status
                that.reloadApiStatus();
            }

            if (callback) {
                if (err) {
                    // eslint-disable-next-line no-console
                    console.error('API error:', err);
                    return callback(err);
                } else {
                    const payload = typeof payloadString === 'string' ? JSON.parse(payloadString) : payloadString;

                    if (payload.type === 'FeatureCollection' || payload.type === 'Feature') {
                        return parseNoteJSON(payload, callback, options);
                    } else {
                        return parseJSON(payload, callback, options);
                    }
                }
            }
        }

        if (this.authenticated()) {
            return oauth.xhr({
                method: 'GET',
                path
            }, done);
        } else {
            var url = apiUrlroot + path;
            var controller = new AbortController();

            d3_json(url, { signal: controller.signal })
                .then(function(data) {
                    done(null, data);
                })
                .catch(function(err) {
                    if (err.name === 'AbortError') return;
                    // d3-fetch includes status in the error message,
                    // but we can't access the response itself
                    // https://github.com/d3/d3-fetch/issues/27
                    var match = err.message.match(/^\d{3}/);
                    if (match) {
                        done(new ApiError(err.message, +match[0], { cause: err }));
                    } else {
                        done(err.message);
                    }
                });
            return controller;
        }
    },


    // Load a single entity by id (ways and relations use the `/full` call to include
    // nodes and members). Parent relations are not included, see `loadEntityRelations`.
    // GET /api/0.6/node/#id
    // GET /api/0.6/[way|relation]/#id/full
    loadEntity: function(id, callback) {
        var type = osmIdManager.type(id);
        var osmID = osmIdManager.toOSM(id);
        var options = { skipSeen: false };

        this.loadFromAPI(
            '/api/0.6/' + type + '/' + osmID + (type !== 'node' ? '/full' : '') + '.json',
            function(err, entities) {
                if (callback) callback(err, { data: entities });
            },
            options
        );
    },

    // Load a single note by id , XML format
    // GET /api/0.6/notes/#id
    loadEntityNote: function(id, callback) {
        var options = { skipSeen: false };
        this.loadFromAPI(
            `/api/0.6/notes/${id}.json`,
            function(err, entities) {
                if (callback) callback(err, { data: entities });
            },
            options
        );
    },


    // Load a single entity with a specific version
    // GET /api/0.6/[node|way|relation]/#id/#version
    loadEntityVersion: function(id, version, callback) {
        var type = osmIdManager.type(id);
        var osmID = osmIdManager.toOSM(id);
        var options = { skipSeen: false };

        this.loadFromAPI(
            '/api/0.6/' + type + '/' + osmID + '/' + version + '.json',
            function(err, entities) {
                if (callback) callback(err, { data: entities });
            },
            options
        );
    },


    // Load the relations of a single entity with the given.
    // GET /api/0.6/[node|way|relation]/#id/relations
    loadEntityRelations: function(id, callback) {
        var type = osmIdManager.type(id);
        var osmID = osmIdManager.toOSM(id);
        var options = { skipSeen: false };

        this.loadFromAPI(
            '/api/0.6/' + type + '/' + osmID + '/relations.json',
            function(err, entities) {
                if (callback) callback(err, { data: entities });
            },
            options
        );
    },


    // Load multiple entities in chunks
    // (note: callback may be called multiple times)
    // Unlike `loadEntity`, child nodes and members are not fetched
    // GET /api/0.6/[nodes|ways|relations]?#parameters
    loadMultiple: function(ids, callback) {
        var that = this;
        var groups = utilArrayGroupBy(utilArrayUniq(ids), osmIdManager.type);

        Object.keys(groups).forEach(function(k) {
            var type = k + 's';   // nodes, ways, relations
            var osmIDs = groups[k].map(function(id) { return osmIdManager.toOSM(id); });
            var options = { skipSeen: false };

            utilArrayChunk(osmIDs, 150).forEach(function(arr) {
                that.loadFromAPI(
                    '/api/0.6/' + type + '.json?' + type + '=' + arr.join(),
                    function(err, entities) {
                        if (callback) callback(err, { data: entities });
                    },
                    options
                );
            });
        });
    },


    // Create, upload, and close a changeset
    // PUT /api/0.6/changeset/create
    // POST /api/0.6/changeset/#id/upload
    // PUT /api/0.6/changeset/#id/close
    putChangeset: function(changeset, changes, callback) {
        var cid = _connectionID;

        if (_changeset.inflight) {
            return callback(new ApiError('Changeset already inflight', -2), changeset);

        } else if (_changeset.open) {   // reuse existing open changeset..
            return createdChangeset.call(this, null, _changeset.open);

        } else {   // Open a new changeset..
            var options = {
                method: 'PUT',
                path: '/api/0.6/changeset/create',
                headers: { 'Content-Type': 'text/xml' },
                content: JXON.stringify(changeset.asJXON())
            };
            _changeset.inflight = oauth.xhr(
                options,
                wrapcb(this, createdChangeset, cid)
            );
        }


        async function createdChangeset(err, changesetID) {
            _changeset.inflight = null;
            if (err) { return callback(err, changeset); }

            _changeset.open = changesetID;
            changeset = changeset.update({ id: changesetID });

            // Upload the changeset..
            const xml = JXON.stringify(changeset.osmChangeJXON(changes));
            const compressed = await utilGzip(xml);

            const headers = { 'Content-Type': 'text/xml' };
            if (compressed) headers['Content-Encoding'] = 'gzip';

            var options = {
                method: 'POST',
                path: '/api/0.6/changeset/' + changesetID + '/upload',
                headers,
                content: compressed || xml,
            };
            _changeset.inflight = oauth.xhr(
                options,
                wrapcb(this, uploadedChangeset, cid)
            );
        }


        function uploadedChangeset(err) {
            _changeset.inflight = null;
            if (err) return callback(err, changeset);

            // Upload was successful, safe to call the callback.
            // Add delay to allow for postgres replication #1646 #2678
            window.setTimeout(function() { callback(null, changeset); }, 2500);
            _changeset.open = null;

            // At this point, we don't really care if the connection was switched..
            // Only try to close the changeset if we're still talking to the same server.
            if (this.getConnectionId() === cid) {
                // Still attempt to close changeset, but ignore response because #2667
                oauth.xhr({
                    method: 'PUT',
                    path: '/api/0.6/changeset/' + changeset.id + '/close',
                    headers: { 'Content-Type': 'text/xml' }
                }, function() { return true; });
            }
        }
    },

    /** updates the tags on an existing unclosed changeset */
    // PUT /api/0.6/changeset/#id
    updateChangesetTags: (changeset) => {
        return oauth.fetch(`${oauth.options().apiUrl}/api/0.6/changeset/${changeset.id}`, {
            method: 'PUT',
            headers: { 'Content-Type': 'text/xml' },
            body: JXON.stringify(changeset.asJXON())
        });
    },


    // Load multiple users in chunks
    // (note: callback may be called multiple times)
    // GET /api/0.6/users?users=#id1,#id2,...,#idn
    loadUsers: function(uids, callback) {
        var toLoad = [];
        var cached = [];

        utilArrayUniq(uids).forEach(function(uid) {
            if (_userCache.user[uid]) {
                delete _userCache.toLoad[uid];
                cached.push(_userCache.user[uid]);
            } else {
                toLoad.push(uid);
            }
        });

        if (cached.length || !this.authenticated()) {
            callback(undefined, cached);
            if (!this.authenticated()) return;  // require auth
        }

        utilArrayChunk(toLoad, 150).forEach(function(arr) {
            oauth.xhr({
                method: 'GET',
                path: '/api/0.6/users.json?users=' + arr.join()
            }, wrapcb(this, done, _connectionID));
        }.bind(this));

        function done(err, payload) {
            if (err) return callback(err);

            var options = { skipSeen: true };
            return parseUserJSON(payload, function(err, results) {
                if (err) return callback(err);
                return callback(undefined, results);
            }, options);
        }
    },


    // Load a given user by id
    // GET /api/0.6/user/#id
    loadUser: function(uid, callback) {
        if (_userCache.user[uid] || !this.authenticated()) {   // require auth
            delete _userCache.toLoad[uid];
            return callback(undefined, _userCache.user[uid]);
        }

        oauth.xhr({
            method: 'GET',
            path: '/api/0.6/user/' + uid + '.json'
        }, wrapcb(this, done, _connectionID));

        function done(err, payload) {
            if (err) return callback(err);

            var options = { skipSeen: true };
            return parseUserJSON(payload, function(err, results) {
                if (err) return callback(err);
                return callback(undefined, results[0]);
            }, options);
        }
    },


    // Load the details of the logged-in user
    // GET /api/0.6/user/details
    userDetails: function(callback) {
        if (_userDetails) {    // retrieve cached
            return callback(undefined, _userDetails);
        }

        oauth.xhr({
            method: 'GET',
            path: '/api/0.6/user/details.json'
        }, wrapcb(this, done, _connectionID));

        function done(err, payload) {
            if (err) return callback(err);

            var options = { skipSeen: false };
            return parseUserJSON(payload, function(err, results) {
                if (err) return callback(err);
                _userDetails = results[0];
                return callback(undefined, _userDetails);
            }, options);
        }
    },


    // Load previous changesets for the logged in user
    // GET /api/0.6/changesets?user=#id
    userChangesets: function(callback) {
        if (_userChangesets) {    // retrieve cached
            return callback(undefined, _userChangesets);
        }

        this.userDetails(
            wrapcb(this, gotDetails, _connectionID)
        );


        function gotDetails(err, user) {
            if (err) { return callback(err); }

            oauth.xhr({
                method: 'GET',
                path: `/api/0.6/changesets.json?user=${user.id}`
            }, wrapcb(this, done, _connectionID));
        }

        function done(err, payloadString) {
            if (err) { return callback(err); }

            const payload = JSON.parse(payloadString);
            _userChangesets = payload.changesets.filter(tags => tags.tags.comment);

            return callback(undefined, _userChangesets);
        }
    },


    // Fetch the status of the OSM API
    // GET /api/capabilities
    status: function(callback) {
        const url = `${apiUrlroot}/api/capabilities.json`;
        var errback = wrapcb(this, done, _connectionID);
        d3_json(url)
            .then(function(data) { errback(null, data); })
            .catch(function(err) { errback(err.message); });

        function done(err, payload) {
            if (err) {
                // the status is null if no response could be retrieved
                return callback(err, null);
            }

            if (_rateLimitError) {
                return callback(_rateLimitError, 'rateLimited');
            } else {
                _maxWayNodes = payload.api.waynodes.maximum;

                _imageryBlocklists = payload.policy.imagery.blacklist.map(item => new RegExp(item.regex, 'i'));

                const maxChangesetElements = payload.api.changesets.maximum_elements;
                if (!Number.isNaN(maxChangesetElements)) _maxChangesetElements = maxChangesetElements;

                return callback(undefined, payload.api.status.api);
            }
        }
    },

    // Calls `status` and dispatches an `apiStatusChange` event if the returned
    // status differs from the cached status.
    reloadApiStatus: function() {
        // throttle to avoid unnecessary API calls
        if (!this.throttledReloadApiStatus) {
            var that = this;
            this.throttledReloadApiStatus = throttle(function() {
                that.status(function(err, status) {
                    if (status !== _cachedApiStatus) {
                        _cachedApiStatus = status;
                        dispatch.call('apiStatusChange', that, err, status);
                    }
                });
            }, 500);
        }
        this.throttledReloadApiStatus();
    },


    // Returns the maximum number of nodes a single way can have
    maxWayNodes: function() {
        return _maxWayNodes;
    },


    maxChangesetElements: () => _maxChangesetElements,


    // Load data (entities) from the API in tiles
    // GET /api/0.6/map?bbox=
    loadTiles: function(projection, callback) {
        if (_off) return;

        // determine the needed tiles to cover the view
        var tiles = tiler.zoomExtent([_tileZoom, _tileZoom]).getTiles(projection);

        // abort inflight requests that are no longer needed
        var hadRequests = hasInflightRequests(_tileCache);
        abortUnwantedRequests(_tileCache, tiles);
        if (hadRequests && !hasInflightRequests(_tileCache)) {
            if (_rateLimitError) {
                // was rate limited, but has settled
                _rateLimitError = undefined;
                dispatch.call('change');
                this.reloadApiStatus();
            }
            dispatch.call('loaded');    // stop the spinner
        }

        // issue new requests..
        tiles.forEach(function(tile) {
            this.loadTile(tile, callback);
        }, this);
    },


    // Load a single data tile
    // GET /api/0.6/map?bbox=
    loadTile: function(tile, callback) {
        if (_off) return;
        if (_tileCache.loaded[tile.id] || _tileCache.inflight[tile.id]) return;

        if (!hasInflightRequests(_tileCache)) {
            dispatch.call('loading');   // start the spinner
        }

        var path = '/api/0.6/map.json?bbox=';
        var options = { skipSeen: true };

        _tileCache.inflight[tile.id] = this.loadFromAPI(
            path + tile.extent.toParam(),
            tileCallback.bind(this),
            options
        );

        function tileCallback(err, parsed) {
            if (!err) {
                delete _tileCache.inflight[tile.id];
                delete _tileCache.toLoad[tile.id];
                _tileCache.loaded[tile.id] = true;
                var bbox = tile.extent.bbox();
                bbox.id = tile.id;
                _tileCache.rtree.insert(bbox);
            } else {
                // map tile loading error: e.g. network connection error,
                // 509 Bandwidth Limit Exceeded, 429 Too Many Requests
                if (!_rateLimitError && err.status === 509 || err.status === 429) {
                    // show "API rate limiting" warning
                    _rateLimitError = err;
                    dispatch.call('change');
                    this.reloadApiStatus();
                }
                setTimeout(() => {
                    // retry loading the tiles
                    delete _tileCache.inflight[tile.id];
                    this.loadTile(tile, callback);
                }, 8000);
            }
            if (callback) {
                callback(err, Object.assign({ data: parsed }, tile));
            }
            if (!hasInflightRequests(_tileCache)) {
                if (_rateLimitError) {
                    // was rate limited, but has settled
                    _rateLimitError = undefined;
                    dispatch.call('change');
                    this.reloadApiStatus();
                }
                dispatch.call('loaded');     // stop the spinner
            }
        }
    },


    isDataLoaded: function(loc) {
        var bbox = { minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1] };
        return _tileCache.rtree.collides(bbox);
    },


    // load the tile that covers the given `loc`
    loadTileAtLoc: function(loc, callback) {
        // Back off if the toLoad queue is filling up.. re #6417
        // (Currently `loadTileAtLoc` requests are considered low priority - used by operations to
        // let users safely edit geometries which extend to unloaded tiles.  We can drop some.)
        if (Object.keys(_tileCache.toLoad).length > 50) return;

        var k = geoZoomToScale(_tileZoom + 1);
        var offset = geoRawMercator().scale(k)(loc);
        var projection = geoRawMercator().transform({ k: k, x: -offset[0], y: -offset[1] });
        var tiles = tiler.zoomExtent([_tileZoom, _tileZoom]).getTiles(projection);

        tiles.forEach(function(tile) {
            if (_tileCache.toLoad[tile.id] || _tileCache.loaded[tile.id] || _tileCache.inflight[tile.id]) return;

            _tileCache.toLoad[tile.id] = true;
            this.loadTile(tile, callback);
        }, this);
    },


    // Load notes from the API in tiles
    // GET /api/0.6/notes?bbox=
    loadNotes: function(projection, noteOptions) {
        noteOptions = Object.assign({ limit: 10000, closed: 7 }, noteOptions);
        if (_off) return;

        var that = this;
        var path = `/api/0.6/notes.json?limit=${noteOptions.limit}&closed=${noteOptions.closed}&bbox=`;
        var throttleLoadUsers = throttle(function() {
            var uids = Object.keys(_userCache.toLoad);
            if (!uids.length) return;
            that.loadUsers(uids, function() {});  // eagerly load user details
        }, 750);

        // determine the needed tiles to cover the view
        var tiles = tiler.zoomExtent([_noteZoom, _noteZoom]).getTiles(projection);

        // abort inflight requests that are no longer needed
        abortUnwantedRequests(_noteCache, tiles);

        // issue new requests..
        tiles.forEach(function(tile) {
            if (_noteCache.loaded[tile.id] || _noteCache.inflight[tile.id]) return;

            var options = { skipSeen: false };
            _noteCache.inflight[tile.id] = that.loadFromAPI(
                path + tile.extent.toParam(),
                function(err) {
                    delete _noteCache.inflight[tile.id];
                    if (!err) {
                        _noteCache.loaded[tile.id] = true;
                    }
                    throttleLoadUsers();
                    dispatch.call('loadedNotes');
                },
                options
            );
        });
    },


    // Create a note
    // POST /api/0.6/notes?params
    postNoteCreate: function(note, callback) {
        if (!this.authenticated()) {
            return callback(new ApiError('Not Authenticated', -3), note);
        }
        if (_noteCache.inflightPost[note.id]) {
            return callback(new ApiError('Note update already inflight', -2), note);
        }

        if (!note.loc[0] || !note.loc[1] || !note.newComment) return; // location & description required

        var comment = note.newComment;
        if (note.newCategory && note.newCategory !== 'None') { comment += ' #' + note.newCategory; }

        var path = '/api/0.6/notes.json?' + utilQsString({ lon: note.loc[0], lat: note.loc[1], text: comment });

        _noteCache.inflightPost[note.id] = oauth.xhr({
            method: 'POST',
            path: path
        }, wrapcb(this, done, _connectionID));


        function done(err, payload) {
            delete _noteCache.inflightPost[note.id];
            if (err) { return callback(err); }

            // we get the updated note back, remove from caches and reparse..
            this.removeNote(note);

            var options = { skipSeen: false };
            return parseNoteJSON(JSON.parse(payload), function(err, results) {
                if (err) {
                    return callback(err);
                } else {
                    return callback(undefined, results[0]);
                }
            }, options);
        }
    },


    // Update a note
    // POST /api/0.6/notes/#id/comment?text=comment
    // POST /api/0.6/notes/#id/close?text=comment
    // POST /api/0.6/notes/#id/reopen?text=comment
    postNoteUpdate: function(note, newStatus, callback) {
        if (!this.authenticated()) {
            return callback(new ApiError('Not Authenticated', -3), note);
        }
        if (_noteCache.inflightPost[note.id]) {
            return callback(new ApiError('Note update already inflight', -2), note);
        }

        var action;
        if (note.status !== 'closed' && newStatus === 'closed') {
            action = 'close';
        } else if (note.status !== 'open' && newStatus === 'open') {
            action = 'reopen';
        } else {
            action = 'comment';
            if (!note.newComment) return; // when commenting, comment required
        }

        var path = `/api/0.6/notes/${note.id}/${action}.json`;
        if (note.newComment) {
            path += '?' + utilQsString({ text: note.newComment });
        }

        _noteCache.inflightPost[note.id] = oauth.xhr({
            method: 'POST',
            path: path
        }, wrapcb(this, done, _connectionID));


        function done(err, payload) {
            delete _noteCache.inflightPost[note.id];
            if (err) { return callback(err); }

            // we get the updated note back, remove from caches and reparse..
            this.removeNote(note);

            // update closed note cache - used to populate `closed:note` changeset tag
            if (action === 'close') {
                _noteCache.closed[note.id] = true;
            } else if (action === 'reopen') {
                delete _noteCache.closed[note.id];
            }

            var options = { skipSeen: false };
            return parseNoteJSON(JSON.parse(payload), function(err, results) {
                if (err) {
                    return callback(err);
                } else {
                    return callback(undefined, results[0]);
                }
            }, options);
        }
    },


    /* connection options for source switcher (optional) */
    apiConnections: function(val) {
        if (!arguments.length) return _apiConnections;
        _apiConnections = val;
        return this;
    },


    switch: function(newOptions) {
        urlroot = newOptions.url;
        apiUrlroot = newOptions.apiUrl || urlroot;
        if (newOptions.url && !newOptions.apiUrl) {
            newOptions = {
                ...newOptions,
                apiUrl: newOptions.url
            };
        }

        // Copy the existing options, but omit 'access_token'.
        // (if we did preauth, access_token won't work on a different server)
        const oldOptions = utilObjectOmit(oauth.options(), 'access_token');
        oauth.options({...oldOptions, ...newOptions});

        this.reset();
        this.userChangesets(function() {});  // eagerly load user details/changesets
        dispatch.call('change');
        return this;
    },


    toggle: function(val) {
        _off = !val;
        return this;
    },


    isChangesetInflight: function() {
        return !!_changeset.inflight;
    },


    // get/set cached data
    // This is used to save/restore the state when entering/exiting the walkthrough
    // Also used for testing purposes.
    caches: function(obj) {
        function cloneCache(source) {
            var target = {};
            Object.keys(source).forEach(function(k) {
                if (k === 'rtree') {
                    target.rtree = new RBush().fromJSON(source.rtree.toJSON());  // clone rbush
                } else if (k === 'note') {
                    target.note = {};
                    Object.keys(source.note).forEach(function(id) {
                        target.note[id] = osmNote(source.note[id]);   // copy notes
                    });
                } else {
                    target[k] = JSON.parse(JSON.stringify(source[k]));   // clone deep
                }
            });
            return target;
        }

        if (!arguments.length) {
            return {
                tile: cloneCache(_tileCache),
                note: cloneCache(_noteCache),
                user: cloneCache(_userCache)
            };
        }

        // access caches directly for testing (e.g., loading notes rtree)
        if (obj === 'get') {
            return {
                tile: _tileCache,
                note: _noteCache,
                user: _userCache
            };
        }

        if (obj.tile) {
            _tileCache = obj.tile;
            _tileCache.inflight = {};
        }
        if (obj.note) {
            _noteCache = obj.note;
            _noteCache.inflight = {};
            _noteCache.inflightPost = {};
        }
        if (obj.user) {
            _userCache = obj.user;
        }

        return this;
    },


    logout: function() {
        _userChangesets = undefined;
        _userDetails = undefined;
        oauth.logout();
        dispatch.call('change');
        return this;
    },


    authenticated: function() {
        return oauth.authenticated();
    },


    /** @param {import('osm-auth').LoginOptions} options */
    authenticate: function(callback, options) {
        var that = this;
        var cid = _connectionID;
        _userChangesets = undefined;
        _userDetails = undefined;

        function done(err, res) {
            if (err) {
                if (callback) callback(err);
                return;
            }
            if (that.getConnectionId() !== cid) {
                if (callback) callback(new ApiError('Connection Switched', -1));
                return;
            }
            _rateLimitError = undefined;
            dispatch.call('change');
            if (callback) callback(err, res);
            that.userChangesets(function() {});  // eagerly load user details/changesets
        }

        // ensure the locale is correctly set before opening the popup
        oauth.options({
            ...oauth.options(),
            locale: localizer.localeCode(),
        });

        oauth.authenticate(done, options);
    },


    imageryBlocklists: function() {
        return _imageryBlocklists;
    },


    tileZoom: function(val) {
        if (!arguments.length) return _tileZoom;
        _tileZoom = val;
        return this;
    },


    // get all cached notes covering the viewport
    notes: function(projection) {
        var viewport = projection.clipExtent();
        var min = [viewport[0][0], viewport[1][1]];
        var max = [viewport[1][0], viewport[0][1]];
        var bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox();

        return _noteCache.rtree.search(bbox)
            .map(function(d) { return d.data; });
    },


    // get a single note from the cache
    getNote: function(id) {
        return _noteCache.note[id];
    },


    // remove a single note from the cache
    removeNote: function(note) {
        if (!(note instanceof osmNote) || !note.id) return;

        delete _noteCache.note[note.id];
        updateRtree(encodeNoteRtree(note), false);  // false = remove
    },


    // replace a single note in the cache
    replaceNote: function(note) {
        if (!(note instanceof osmNote) || !note.id) return;

        const item = encodeNoteRtree(_noteCache.note[note.id] || note);
        _noteCache.note[note.id] = note;
        updateRtree(item, encodeNoteRtree(note));
        return note;
    },


    // Get an array of note IDs closed during this session.
    // Used to populate `closed:note` changeset tag
    getClosedIDs: function() {
        return Object.keys(_noteCache.closed).sort();
    }

};
