import { dispatch as d3_dispatch } from 'd3-dispatch';

import Protobuf from 'pbf';
import RBush from 'rbush';
import { VectorTile } from '@mapbox/vector-tile';

import { utilRebind, utilTiler, utilQsString, utilStringQs, utilUniqueDomId} from '../util';
import { geoExtent, geoScaleToZoom } from '../geo';
import { t, localizer } from '../core/localizer';
import pannellumPhotoFrame from './pannellum_photo';
import planePhotoFrame from './plane_photo';
import { services } from './';


const apiUrl = 'https://api.panoramax.xyz/';
const tileUrl = apiUrl + 'api/map/{z}/{x}/{y}.mvt';
const imageDataUrl = apiUrl + 'api/collections/{collectionId}/items/{itemId}';
const sequenceDataUrl = apiUrl + 'api/collections/{collectionId}/items?limit=1000';
const userIdUrl = apiUrl + 'api/users/search?q={username}';
const usernameURL = apiUrl + 'api/users/{userId}';
const viewerUrl = apiUrl;

const highDefinition = 'hd';
const standardDefinition = 'sd';

const pictureLayer = 'pictures';
const sequenceLayer = 'sequences';

const minZoom = 10;
const imageMinZoom = 15;
const lineMinZoom = 10;
const dispatch = d3_dispatch('loadedImages', 'loadedLines', 'viewerChanged');

let _cache;
let _loadViewerPromise;
let _definition = standardDefinition;
let _isHD = false;

let _planeFrame;
let _pannellumFrame;
let _currentFrame;

let _currentScene = {
    currentImage : null,
    nextImage : null,
    prevImage : null
};

let _activeImage;
let _isViewerOpen = false;


// Partition viewport into higher zoom tiles
function partitionViewport(projection) {
    const z = geoScaleToZoom(projection.scale());
    const z2 = (Math.ceil(z * 2) / 2) + 2.5;   // round to next 0.5 and add 2.5
    const tiler = utilTiler().zoomExtent([z2, z2]);

    return tiler.getTiles(projection)
        .map(function(tile) { return tile.extent; });
}

/**
 * Return no more than `limit` results per partition.
 * @param {number} limit Number of maximum objects to return
 * @param {*} projection Current projection
 * @param {*} rtree The cache
 * @returns Data found
 */
function searchLimited(limit, projection, rtree) {
    limit = limit || 5;

    return partitionViewport(projection)
        .reduce(function(result, extent) {
            let found = rtree.search(extent.bbox());
            const spacing = Math.max(1, Math.floor(found.length / limit));
            found = found
                .filter((d, idx) => idx % spacing === 0 ||
                                    d.data.id === _activeImage?.id)
                .sort((a, b) => {
                    if (a.data.id === _activeImage?.id) return -1;
                    if (b.data.id === _activeImage?.id) return  1;
                    return 0;
                })
                .slice(0, limit)
                .map(d => d.data);

            return (found.length ? result.concat(found) : result);
        }, []);
}

/**
 * Load all data for the specified type from Panoramax vector tiles
 * @param {string} which Either 'images' or 'lines'
 * @param {string} url Tile endpoint
 * @param {number} maxZoom Maximum zoom out
 * @param {*} projection Current projection
 * @param {number} zoom current zoom
 */
function loadTiles(which, url, maxZoom, projection, zoom) {
    const tiler = utilTiler().zoomExtent([minZoom, maxZoom]).skipNullIsland(true);
    const tiles = tiler.getTiles(projection);

    tiles.forEach(function(tile) {
        loadTile(which, url, tile, zoom);
    });
}

/**
 * Load all data for the specified type from one vector tile
 * @param {*} which Either 'images' or 'lines'
 * @param {*} url Tile endpoint
 * @param {*} tile Current tile
 * @param {*} zoom Current zoom
 */
function loadTile(which, url, tile, zoom) {
    const cache = _cache.requests;
    const tileId = `${tile.id}-${which}`;
    if (cache.loaded[tileId] || cache.inflight[tileId]) return;
    const controller = new AbortController();
    cache.inflight[tileId] = controller;
    const requestUrl = url.replace('{x}', tile.xyz[0])
        .replace('{y}', tile.xyz[1])
        .replace('{z}', tile.xyz[2]);

    fetch(requestUrl, { signal: controller.signal })
        .then(function(response) {
            if (!response.ok) {
                throw new Error(response.status + ' ' + response.statusText);
            }
            cache.loaded[tileId] = true;
            delete cache.inflight[tileId];
            return response.arrayBuffer();
        })
        .then(function(data) {
            if (data.byteLength === 0) {
                throw new Error('No Data');
            }

            loadTileDataToCache(data, tile, zoom);

            if (which === 'images') {
                dispatch.call('loadedImages');
            } else {
                dispatch.call('loadedLines');
            }
        })
        .catch(function (e) {
            if (e.message === 'No Data') {
                cache.loaded[tileId] = true;
            } else {
                console.error(e); // eslint-disable-line no-console
            }
        });
}

/**
 * Fetches all data for the specified tile and adds them to cache
 * @param {*} data Tile data
 * @param {*} tile Current tile
 * @param {*} zoom Current zoom
 */
function loadTileDataToCache(data, tile, zoom) {
    const vectorTile = new VectorTile(new Protobuf(data));

    let features,
        cache,
        layer,
        i,
        feature,
        loc,
        d;

    if (vectorTile.layers.hasOwnProperty(pictureLayer)) {
        features = [];
        cache = _cache.images;
        layer = vectorTile.layers[pictureLayer];

        for (i = 0; i < layer.length; i++) {
            feature = layer.feature(i).toGeoJSON(tile.xyz[0], tile.xyz[1], tile.xyz[2]);
            loc = feature.geometry.coordinates;

            d = {
                service: 'photo',
                loc: loc,
                capture_time: feature.properties.ts,
                capture_time_parsed: new Date(feature.properties.ts),
                id: feature.properties.id,
                account_id: feature.properties.account_id,
                sequence_id: feature.properties.first_sequence,
                heading: parseInt(feature.properties.heading, 10),
                image_path: '',
                isPano: feature.properties.type === 'equirectangular',
                model: feature.properties.model,
            };
            cache.forImageId[d.id] = d;
            features.push({
                minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d
            });
        }
        if (cache.rtree) {
            cache.rtree.load(features);
        }
    }

    if (vectorTile.layers.hasOwnProperty(sequenceLayer)) {

        cache = _cache.sequences;

        if (zoom >= lineMinZoom && zoom < imageMinZoom) cache = _cache.mockSequences;

        layer = vectorTile.layers[sequenceLayer];

        for (i = 0; i < layer.length; i++) {
            feature = layer.feature(i).toGeoJSON(tile.xyz[0], tile.xyz[1], tile.xyz[2]);
            if (cache.lineString[feature.properties.id]) {
                cache.lineString[feature.properties.id].push(feature);
            } else {
                cache.lineString[feature.properties.id] = [feature];
            }
        }
    }
}

/**
 * Fetches the username from Panoramax
 * @param {string} userId
 * @returns the username
 */
async function getUsername(userId) {
    const cache = _cache.users;
    if (cache[userId]) return cache[userId].name;

    const requestUrl = usernameURL.replace('{userId}', userId);

    const response = await fetch(requestUrl, { method: 'GET' });
    if (!response.ok) {
        throw new Error(response.status + ' ' + response.statusText);
    }
    const data = await response.json();
    cache[userId] = data;

    return data.name;
}

export default {
    init: function() {
        if (!_cache) {
            this.reset();
        }

        this.event = utilRebind(this, dispatch, 'on');
    },

    reset: function() {
        if (_cache) {
            Object.values(_cache.requests.inflight).forEach(function(request) { request.abort(); });
        }

        _cache = {
            images: { rtree: new RBush(), forImageId: {} },
            sequences: { rtree: new RBush(), lineString: {}, items: {} },
            users: {},
            mockSequences: { rtree: new RBush(), lineString: {} },
            requests: { loaded: {}, inflight: {} }
        };
    },

    /**
     * Get visible images from cache
     * @param {*} projection Current Projection
     * @returns images data for the current projection
     */
    images: function(projection) {
        const limit = 5;
        return searchLimited(limit, projection, _cache.images.rtree);
    },

    /**
     * Get a specific image from cache
     * @param {*} imageKey the image id
     * @returns
     */
    cachedImage: function(imageKey) {
        return _cache.images.forImageId[imageKey];
    },

    /**
     * Fetches images data for the visible area
     * @param {*} projection Current Projection
     */
    loadImages: function(projection) {
        loadTiles('images', tileUrl, imageMinZoom, projection);
    },

    /**
     * Fetches sequences data for the visible area
     * @param {*} projection Current Projection
     */
    loadLines: function(projection, zoom) {
        loadTiles('line', tileUrl, lineMinZoom, projection, zoom);
    },

    /**
     * Fetches all possible userIDs from Panoramax
     * @param {string} usernames one or multiple usernames
     * @returns userIDs
     */
    getUserIds: async function(usernames) {
        const requestUrls = usernames.map(username =>
            userIdUrl.replace('{username}', username));

        const responses = await Promise.all(requestUrls.map(requestUrl =>
            fetch(requestUrl, { method: 'GET' })));
        if (responses.some(response => !response.ok)) {
            const response = responses.find(response => !response.ok);
            throw new Error(response.status + ' ' + response.statusText);
        }
        const data = await Promise.all(responses.map(response => response.json()));
        // in panoramax, a username can have multiple ids, when the same name is
        // used on different servers
        return data.flatMap((d, i) => d.features.filter(f => f.name === usernames[i]).map(f => f.id));
    },

    /**
     * Get visible sequences from cache
     * @param {*} projection Current Projection
     * @param {number} zoom Current zoom (if zoom < `lineMinZoom` less accurate lines will be drawn)
     * @returns sequences data for the current projection
     */
    sequences: function(projection, zoom) {
        const viewport = projection.clipExtent();
        const min = [viewport[0][0], viewport[1][1]];
        const max = [viewport[1][0], viewport[0][1]];
        const bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox();
        const sequenceIds = {};
        let lineStrings = [];

        if (zoom >= imageMinZoom){
            _cache.images.rtree.search(bbox).forEach(function(d) {
                    if (d.data.sequence_id) {
                        sequenceIds[d.data.sequence_id] = true;
                    }
                });
                Object.keys(sequenceIds).forEach(function(sequenceId) {
                    if (_cache.sequences.lineString[sequenceId]) {
                        lineStrings = lineStrings.concat(_cache.sequences.lineString[sequenceId]);
                    }
                });
                return lineStrings;
        }
        if (zoom >= lineMinZoom){
            Object.keys(_cache.mockSequences.lineString).forEach(function(sequenceId) {
                lineStrings = lineStrings.concat(_cache.mockSequences.lineString[sequenceId]);
            });
        }
        return lineStrings;
    },

    /**
     * Updates the data for the currently visible image
     * @param {*} image Image data
     */
    setActiveImage: function(image) {
        if (image && image.id && image.sequence_id) {
            _activeImage = {
                id: image.id,
                sequence_id: image.sequence_id,
                loc: image.loc
            };
        } else {
            _activeImage = null;
        }
    },

    getActiveImage: function(){
        return _activeImage;
    },

    /**
     * Update the currently highlighted sequence and selected bubble
     * @param {*} context Current HTML context
     * @param {*} [hovered] The hovered bubble image
     */
    setStyles: function(context, hovered) {
        const hoveredImageId =  hovered && hovered.id;
        const hoveredSequenceId = hovered && hovered.sequence_id;
        const selectedSequenceId = _activeImage && _activeImage.sequence_id;
        const selectedImageId = _activeImage && _activeImage.id;

        const markers = context.container().selectAll('.layer-panoramax .viewfield-group');
        const sequences = context.container().selectAll('.layer-panoramax .sequence');

        markers
            .classed('highlighted', function(d) { return d.sequence_id === selectedSequenceId || d.id === hoveredImageId; })
            .classed('hovered', function(d) { return d.id === hoveredImageId; })
            .classed('currentView', function(d) { return d.id === selectedImageId; });

        sequences
            .classed('highlighted', function(d) { return d.properties.id === hoveredSequenceId; })
            .classed('currentView', function(d) { return d.properties.id === selectedSequenceId; });

        // update viewfields if needed
        context.container().selectAll('.layer-panoramax .viewfield-group .viewfield')
            .attr('d', viewfieldPath);

        function viewfieldPath() {
            let d = this.parentNode.__data__;
            if (d.isPano && d.id !== selectedImageId) {
                return 'M 8,13 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0';
            } else {
                return 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z';
            }
        }
        return this;
    },

    // Get viewer status
    isViewerOpen: function() {
        return _isViewerOpen;
    },

    /**
     * Updates the URL to save the current shown image
     * @param {*} imageKey
     */
    updateUrlImage: function(imageKey) {
        const hash = utilStringQs(window.location.hash);
        if (imageKey) {
            hash.photo = 'panoramax/' + imageKey;
        } else {
            delete hash.photo;
        }
        window.history.replaceState(null, '', '#' + utilQsString(hash, true));
    },

    /**
     * Loads the selected image in the frame
     * @param {*} context Current HTML context
     * @param {*} id of the selected image
     * @returns
     */
    selectImage: function (context, id) {
        let that = this;

        let d = that.cachedImage(id);
        that.setActiveImage(d);
        that.updateUrlImage(d.id);

        const viewerLink = `${viewerUrl}#pic=${d.id}&focus=pic`;

        let viewer = context.container()
            .select('.photoviewer');

        if (!viewer.empty()) viewer.datum(d);

        this.setStyles(context, null);

        if (!d) return this;

        let wrap = context.container()
            .select('.photoviewer .panoramax-wrapper');

        let attribution = wrap.selectAll('.photo-attribution').text('');

        let line1 = attribution
            .append('div')
            .attr('class', 'attribution-row');

        const hdDomId = utilUniqueDomId('panoramax-hd');

        let label = line1
            .append('label')
            .attr('for', hdDomId)
            .attr('class', 'panoramax-hd');

        label
            .append('input')
            .attr('type', 'checkbox')
            .attr('id', hdDomId)
            .property('checked', _isHD)
            .on('click', (d3_event) => {
                d3_event.stopPropagation();
                _isHD = !_isHD;
                _definition = _isHD ? highDefinition : standardDefinition;
                that.selectImage(context, d.id)
                    .showViewer(context);
            });

        label
            .append('span')
            .call(t.append('panoramax.hd'));

        if (d.capture_time) {
            attribution
                .append('span')
                .attr('class', 'captured_at')
                .text(localeDateString(d.capture_time));

            attribution
                .append('span')
                .text('|');
        }

        attribution
            .append('a')
            .attr('class', 'report-photo')
            .attr('href', 'mailto:signalement.ign@panoramax.fr')
            .call(t.append('panoramax.report'));

        attribution
            .append('span')
            .text('|');

        attribution
            .append('a')
            .attr('class', 'image-link')
            .attr('target', '_blank')
            .attr('href', viewerLink)
            .text('panoramax.xyz');

        this.getImageData(d.sequence_id, d.id).then(function(data) {
            _currentScene = {
                currentImage: null,
                nextImage: null,
                prevImage: null
            };
            _currentScene.currentImage = data.assets[_definition];
            const nextIndex = data.links.findIndex(x => x.rel === 'next');
            const prevIndex = data.links.findIndex(x => x.rel === 'prev');

            if (nextIndex !== -1){
                _currentScene.nextImage = data.links[nextIndex];
            }
            if (prevIndex !== -1){
                _currentScene.prevImage = data.links[prevIndex];
            }

            d.image_path = _currentScene.currentImage.href;

            wrap
                .selectAll('button.back')
                .classed('hide', _currentScene.prevImage === null);
            wrap
                .selectAll('button.forward')
                .classed('hide', _currentScene.nextImage === null);

            _currentFrame = d.isPano ? _pannellumFrame : _planeFrame;

            _currentFrame
                .showPhotoFrame(wrap)
                .selectPhoto(d, true);
        });

        function localeDateString(s) {
            if (!s) return null;
            var options = { day: 'numeric', month: 'short', year: 'numeric' };
            var d = new Date(s);
            if (isNaN(d.getTime())) return null;
            return d.toLocaleDateString(localizer.localeCode(), options);
        }

        if (d.account_id) {
            attribution
                .append('span')
                .text('|');

            let line2 = attribution
                .append('span')
                .attr('class', 'attribution-row');

            getUsername(d.account_id).then(function(username){
                line2
                    .append('span')
                    .attr('class', 'captured_by')
                    .text('@' + username);
            });
        }

        return this;
    },

    photoFrame: function() {
        return _currentFrame;
    },

    /**
     * Fetches the data for a specific image
     * @param {*} collectionId
     * @param {*} imageId
     * @returns The fetched image data
     */
    getImageData: async function(collectionId, imageId) {
        const cache = _cache.sequences.items;
        if (cache[collectionId]) {
            const cached = cache[collectionId]
                .find(d => d.id === imageId);
            if (cached) return cached;
        } else {
            // prime the cache with data from sequence
            const response = await fetch(sequenceDataUrl
                .replace('{collectionId}', collectionId),
                { method: 'GET' });

            if (!response.ok) {
                throw new Error(response.status + ' ' + response.statusText);
            }
            const data = (await response.json()).features;
            cache[collectionId] = data;
        }

        const result = cache[collectionId]
            .find(d => d.id === imageId);
        if (result) return result;

        // not found in sequence: retry to load single item data
        // ideally, we'd use the `withPicture` parameter, but it is buggy:
        // https://gitlab.com/panoramax/server/api/-/issues/268
        const itemResponse = await fetch(imageDataUrl
            .replace('{collectionId}', collectionId)
            .replace('{itemId}', imageId),
            { method: 'GET' });

        if (!itemResponse.ok) {
            throw new Error(itemResponse.status + ' ' + itemResponse.statusText);
        }
        const itemData = await itemResponse.json();
        cache[collectionId].push(itemData);
        return itemData;
    },

    ensureViewerLoaded: function(context) {

        let that = this;

        let imgWrap = context.container()
            .select('#ideditor-viewer-panoramax-simple > img');

        if (!imgWrap.empty()) {
            imgWrap.remove();
        }

        if (_loadViewerPromise) return _loadViewerPromise;

        let wrap = context.container()
            .select('.photoviewer')
            .selectAll('.panoramax-wrapper')
            .data([0]);

        let wrapEnter = wrap.enter()
            .append('div')
            .attr('class', 'photo-wrapper panoramax-wrapper')
            .classed('hide', true)
            .on('dblclick.zoom', null);

        wrapEnter
            .append('div')
            .attr('class', 'photo-attribution fillD');

        const controlsEnter = wrapEnter
            .append('div')
            .attr('class', 'photo-controls-wrap')
            .append('div')
            .attr('class', 'photo-controls-panoramax');

        controlsEnter
            .append('button')
            .classed('back', true)
            .on('click.back', step(-1))
            .text('◄');

        controlsEnter
            .append('button')
            .classed('forward', true)
            .on('click.forward', step(1))
            .text('►');

        // Register viewer resize handler
        _loadViewerPromise = Promise.all([
            pannellumPhotoFrame.init(context, wrapEnter),
            planePhotoFrame.init(context, wrapEnter)
          ]).then(([pannellumPhotoFrame, planePhotoFrame]) => {
            _pannellumFrame = pannellumPhotoFrame;
            _pannellumFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged'));
            _planeFrame = planePhotoFrame;
            _planeFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged'));
          });

        /**
         * Loads the next image in the sequence
         * @param {number} stepBy '-1' if backwards or '1' if forward
         * @returns
         */
        function step(stepBy) {
            return function () {
                if (!_currentScene.currentImage) return;

                let nextId;
                if (stepBy === 1) nextId = _currentScene.nextImage.id;
                else nextId = _currentScene.prevImage.id;

                if (!nextId) return;

                const nextImage = _cache.images.forImageId[nextId];

                if (nextImage){
                    context.map().centerEase(nextImage.loc);
                    that.selectImage(context, nextImage.id);
                }
            };
        }

        return _loadViewerPromise;
    },

    /**
     * Shows the current viewer if hidden
     * @param {*} context
     */
    showViewer: function (context) {
        const wrap = context.container().select('.photoviewer');
        const isHidden = wrap.selectAll('.photo-wrapper.panoramax-wrapper.hide').size();
        if (isHidden) {
            for (const service of Object.values(services)) {
                if (service === this) continue;
                if (typeof service.hideViewer === 'function') {
                    service.hideViewer(context);
                }
            }
            wrap.classed('hide', false)
                .selectAll('.photo-wrapper.panoramax-wrapper')
                .classed('hide', false);
        }

        _isViewerOpen = true;
        return this;
    },

    /**
     * Hides the current viewer if shown, resets the active image and sequence
     * @param {*} context
     */
    hideViewer: function (context) {
        let viewer = context.container().select('.photoviewer');
        if (!viewer.empty()) viewer.datum(null);
        this.updateUrlImage(null);
        viewer
            .classed('hide', true)
            .selectAll('.photo-wrapper')
            .classed('hide', true);
        context.container().selectAll('.viewfield-group, .sequence, .icon-sign')
            .classed('currentView', false);

        this.setActiveImage(null);
        _isViewerOpen = false;

        return this.setStyles(context, null);
    },

    cache: function() {
        return _cache;
    }
};
