// SPDX-License-Identifier: LGPL-3.0-or-later
// Copyright 2013 - UDS/CNRS
// The Aladin Lite program is distributed under the terms
// of the GNU Lesser General Public License version 3
// or (at your option) any later version.
//
// This file is part of Aladin Lite.
//
//    Aladin Lite is free software: you can redistribute it and/or modify
//    it under the terms of the GNU Lesser General Public License as published by
//    the Free Software Foundation, either version 3 of the License, or
//    (at your option) any later version.
//
//    Aladin Lite is distributed in the hope that it will be useful,
//    but WITHOUT ANY WARRANTY; without even the implied warranty of
//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//    GNU Lesser General Public License for more details.
//
//    You should have received a copy of the GNU Lesser General Public License
//    along with Aladin Lite. If not, see <https://www.gnu.org/licenses/>.
//

/******************************************************************************
 * Aladin Lite project
 *
 * File Utils
 *
 * Author: Thomas Boch[CDS]
 *
 *****************************************************************************/

import {JSONP_PROXY} from "./Constants";
import { ALEvent } from './events/ALEvent';


export let Utils: any = {}

// list of URL domains that can be safely switched from HTTP to HTTPS
Utils.HTTPS_WHITELIST = ['alasky.u-strasbg.fr', 'alaskybis.u-strasbg.fr', 'alasky.unistra.fr', 'alaskybis.unistra.fr',
    'alasky.cds.unistra.fr', 'alaskybis.cds.unistra.fr', 'hips.astron.nl', 'jvo.nao.ac.jp',
    'archive.cefca.es', 'cade.irap.omp.eu', 'skies.esac.esa.int']

Utils.cssScale = undefined

Utils.relMouseCoords = function (event) {
    if (event.offsetX) {
        return {x: event.offsetX, y: event.offsetY}
    } else {
        if (!Utils.cssScale) {
            var st = window.getComputedStyle(document.body, null)
            var tr = st.getPropertyValue('-webkit-transform') ||
                st.getPropertyValue('-moz-transform') ||
                st.getPropertyValue('-ms-transform') ||
                st.getPropertyValue('-o-transform') ||
                st.getPropertyValue('transform')
            var matrixRegex = /matrix\((-?\d*\.?\d+),\s*0,\s*0,\s*(-?\d*\.?\d+),\s*0,\s*0\)/
            var matches = tr.match(matrixRegex)
            if (matches) {
                Utils.cssScale = parseFloat(matches[1])
            } else {
                Utils.cssScale = 1
            }
        }
        var e = event
        // http://www.jacklmoore.com/notes/mouse-position/
        var target = e.target || e.srcElement
        var style = target.currentStyle || window.getComputedStyle(target, null)
        var borderLeftWidth = parseInt(style['borderLeftWidth'], 10)
        var borderTopWidth = parseInt(style['borderTopWidth'], 10)
        var rect = target.getBoundingClientRect()

        var clientX = e.clientX
        var clientY = e.clientY

        if (e && e.changedTouches) {
            clientX = e.changedTouches[0].clientX
            clientY = e.changedTouches[0].clientY
        }

        var offsetX = clientX - borderLeftWidth - rect.left
        var offsetY = clientY - borderTopWidth - rect.top

        return {x: Math.round(offsetX / Utils.cssScale), y: Math.round(offsetY / Utils.cssScale)}
    }
}

//Function.prototype.bind polyfill from
//https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Function/bind
if (!Function.prototype.bind) {
    Function.prototype.bind = function (obj) {
        // closest thing possible to the ECMAScript 5 internal IsCallable function
        if (typeof this !== 'function') {
            throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable')
        }

        var slice = [].slice,
            args = slice.call(arguments, 1),
            self = this,
            nop = function () {
            },
            bound = function () {
                return self.apply(this instanceof nop ? this : (obj || {}),
                    args.concat(slice.call(arguments)))
            }

        bound.prototype = this.prototype

        return bound
    }
}


/* source : http://stackoverflow.com/a/8764051 */
Utils.urlParam = function (name: string, queryString: string | undefined) {
    if (queryString === undefined) {
        queryString = location.search
    }
    return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(queryString) || [, ''])[1].replace(/\+/g, '%20')) || null
}

/* source: http://stackoverflow.com/a/1830844 */
Utils.isNumber = function (n: string | number) {
    return !isNaN(parseFloat(n as string)) && isFinite(n as number)
}

Utils.isInt = function (n: string | number) {
    return Utils.isNumber(n) && Math.floor(n as number) === n
}

// Newton-Raphson method to find the approximate inverse of f(x)
Utils.inverseNewtonRaphson = function(y: number, f: Function, fPrime: Function, tolerance=1e-6, maxIterations=100) {
    let x_guess = 0.5; // Initial guess
    let iteration = 0;

    while (iteration < maxIterations) {
        let f_x = f(x_guess);
        let error = Math.abs(f_x - y);

        if (error < tolerance) {
            return x_guess; // Found approximate inverse
        }

        let derivative = fPrime(x_guess);
        x_guess = x_guess - (f_x - y) / derivative; // Newton-Raphson update
        iteration++;
    }

    return null; // No convergence within maxIterations
}

Utils.binarySearch = function(array, value) {
    var low = 0,
        high = array.length;
    var mid;
    while (low < high) {
        mid = Math.floor((low + high) / 2);
        if (array[mid] === value) {
            return mid;
        } else if (array[mid] < value) {
            low = mid + 1;
        } else {
            high = mid;
        }
    }

    return low;
}

/* a debounce function, used to prevent multiple calls to the same function if less than delay milliseconds have passed */
Utils.debounce = function (fn, delay) {
    var timer = null
    return function () {
        var context = this, args = arguments
        clearTimeout(timer)
        timer = setTimeout(function () {
            fn.apply(context, args)
        }, delay)
    }
}

/* return a throttled function, to rate limit the number of calls (by default, one call every 250 milliseconds) */
Utils.throttle = function (fn, threshhold, scope) {
    threshhold || (threshhold = 250)
    var last,
        deferTimer
    return function () {
        var context = scope || this

        var now = +new Date,
            args = arguments
        if (last && now < last + threshhold) {
            // hold on to it
            clearTimeout(deferTimer)
            deferTimer = setTimeout(function () {
                last = now
                fn.apply(context, args)
            }, threshhold)
        } else {
            last = now
            fn.apply(context, args)
        }
    }
}

// Way of detecting if the computer has trackpad or a regular mouse wheel thanks to that post:
// https://stackoverflow.com/questions/10744645/detect-touchpad-vs-mouse-in-javascript
Utils.detectTrackPad = function (e) {
    var isTrackpad = false;
    if (e.wheelDeltaY) {
        if (e.wheelDeltaY === (e.deltaY * -3)) {
        isTrackpad = true;
        }
    }
    else if (e.deltaMode === 0) {
        isTrackpad = true;
    }

    return isTrackpad
}


/* A LRU cache, inspired by https://gist.github.com/devinus/409353#file-gistfile1-js */
// TODO : utiliser le LRU cache pour les tuiles images
Utils.LRUCache = function (maxsize) {
    this._keys = []
    this._items = {}
    this._expires = {}
    this._size = 0
    this._maxsize = maxsize || 1024
}

Utils.LRUCache.prototype = {
    set: function (key, value) {
        var keys = this._keys,
            items = this._items,
            expires = this._expires,
            size = this._size,
            maxsize = this._maxsize

        if (size >= maxsize) { // remove oldest element when no more room
            keys.sort(function (a, b) {
                if (expires[a] > expires[b]) return -1
                if (expires[a] < expires[b]) return 1
                return 0
            })

            size--
        }

        keys[size] = key
        items[key] = value
        expires[key] = Date.now()
        size++

        this._keys = keys
        this._items = items
        this._expires = expires
        this._size = size
    },

    get: function (key) {
        var item = this._items[key]
        if (item) {
            this._expires[key] = Date.now()
        }
        return item
    },

    keys: function () {
        return this._keys
    }
}

////////////////////////////////////////////////////////////////////////////:

/**
 Fetch an url with the method GET, given a list of potential mirrors
 An optional object can be given with the following keywords accepted:
 * data: an object storing the params associated to the URL
 * contentType: specify the content type returned from the url (no verification is done, it is not mandatory to put it)
 * timeout: A maximum request time. If exceeded, the request is aborted and the next url will be fetched
 This method assumes the URL are CORS-compatible, no proxy will be used

 A promise is returned. When all the urls fail, a rejected Promise is returned so that it can be catched afterwards
 */
Utils.loadFromUrls = function (urls, options) {
    const data = options && options.data || undefined
    const timeout = options && options.timeout || 5000
    const mode = options && options.mode || 'cors';
    const contentType = options && options.contentType || undefined;
    const dataType = (options && options.dataType) || 'text';
    const desc = options && options.desc;
    // A controller that can abort the query when a timeout is reached
    const controller = new AbortController()

    // Launch a timemout that will interrupt the fetch if it has not yet succeded:
    const timeoutId = setTimeout(() => controller.abort(), timeout)
    let url = urls[0];
    if (data) {
        url += '?' + new URLSearchParams(data)
    }

    let params = {
        url,
        // *GET, POST, PUT, DELETE, etc.
        method: 'GET',
        /*headers: {
            'Content-Type': contentType
        },*/
        // no-cors, *cors, same-origin
        mode,
        // *default, no-cache, reload, force-cache, only-if-cached
        cache: 'default',
        // Abort the request when a timeout exceeded
        signal: controller.signal,
        dataType,
        // message description
        desc
    }

    if (contentType) {
        params.headers = {
            'Content-Type': contentType
        };
    }

    return Utils.fetch({
        ...params,
        success: (resp) => {
            // completed request before timeout fired
            clearTimeout(timeoutId)
            return Promise.resolve(resp)
        },
        error: (e) => {
            // The request aborted because it was to slow, fetch the next url given recursively
            // Base case, when all urls have been fetched and failed
            if (urls && urls.length === 1) {
                let errorMsg = "Status: "+ e.status +"\nMessage: " + e.statusText;
                return Promise.reject(errorMsg);
            } else {
                return Utils.loadFromUrls(urls.slice(1), options);
            }
        }
    })
}

/*
// return the jquery ajax object configured with the requested parameters
// by default, we use the proxy (safer, as we don't know if the remote server supports CORS)
Utils.getAjaxObject = function (url, method, dataType, useProxy) {
    if (useProxy !== false) {
        useProxy = true
    }

    if (useProxy === true) {
        var urlToRequest = JSONP_PROXY + '?url=' + encodeURIComponent(url)
    } else {
        urlToRequest = url
    }
    method = method || 'GET'
    dataType = dataType || null

    return $.ajax({
        url: urlToRequest,
        method: method,
        dataType: dataType
    })
}
*/

Utils.fetch = function(params) {
    let url;
    try {
        url = new URL(params.url);
        if (params.useProxy === true) {
            url = Utils.handleCORSNotSameOrigin(url)
        }
    
        if (params.data) {
            // add the search params to the url object 
            for (const key in params.data) {
                url.searchParams.append(key, params.data[key]);
            }
        }
    } catch(e) {
        // localhost url
        url = params.url;
    }

    let request = new Request(url, {
        method: params.method || 'GET',
        ...params
    })

    let task = {
        message: params.desc || 'fetching: ' + url,
        id: Utils.uuidv4()
    };
    ALEvent.FETCH.dispatchedTo(document, {task});

    return fetch(request)
        .then((resp) => {
            if (params.dataType && params.dataType.includes('json')) {
                return resp.json();
            } else if (params.dataType && params.dataType.includes('blob')) {
                return resp.blob();
            } else if (params.dataType && params.dataType.includes('arrayBuffer')) {
                return resp.arrayBuffer();
            } else if (params.dataType && params.dataType.includes('readableStream')) {
                return Promise.resolve(resp.body);
            } else {
                return resp.text()
            }
        })
        .then(data => {
            if (params.success) {
                return params.success(data)
            }

            return Promise.resolve(data);
        })
        .catch(e => {
            if (params.error) {
                return params.error(e)
            }

            return Promise.reject(e);
        })
        .finally(() => {
            ALEvent.RESOURCE_FETCHED.dispatchedTo(document, {task});
        })
}

Utils.fetchWithProxy = function(params) {
    return Utils.fetch({
        ...params,
        error: (e) => {
            console.error(e)
            console.info("Trying querying the proxy:" + JSONP_PROXY)
            // Try as CORS
            const url = JSONP_PROXY + '?url=' + params.url;
            return Utils.fetch({
                ...params,
                url,
            });
        }
    })
}

Utils.on = function(element, events, callback) {
    events.split(' ')
        .forEach(e => {
            element.addEventListener(e, callback)
        })
}

Utils.isTouchScreenDevice = undefined;

Utils.hasTouchScreen = function() {
    if (Utils.isTouchScreenDevice === undefined) {
        let hasTouchScreen = false;
        if ("maxTouchPoints" in navigator) {
            hasTouchScreen = navigator.maxTouchPoints > 0;
        } else if ("msMaxTouchPoints" in navigator) {
            hasTouchScreen = navigator.msMaxTouchPoints > 0;
        } else {
            const mQ = matchMedia?.("(pointer:coarse)");
            if (mQ?.media === "(pointer:coarse)") {
                hasTouchScreen = !!mQ.matches;
            } else if ("orientation" in window) {
                hasTouchScreen = true; // deprecated, but good fallback
            } else {
                // Only as a last resort, fall back to user agent sniffing
                const UA = navigator.userAgent;
                hasTouchScreen =
                /\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) ||
                /\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA);
            }
        }

        Utils.isTouchScreenDevice = hasTouchScreen;
    }

    return Utils.isTouchScreenDevice;
}



Utils.openNewTab = function(url) {
    window.open(url, '_blank').focus();
}

// return true if script is executed in a HTTPS context
// return false otherwise
Utils.isFileContext = function () {
    return (window.location.protocol === 'file:')
}

Utils.isHttpsContext = function () {
    return (window.location.protocol === 'https:')
}

Utils.isHttpContext = function () {
    return (window.location.protocol === 'http:')
}

Utils.fixURLForHTTPS = function (url) {
    const switchToHttps = Utils.HTTPS_WHITELIST.some(element => {
        return url.includes(element)
    })

    if (switchToHttps) {
        return url.replace('http://', 'https://')
    }

    return url
}

Utils.isUrl = function(url) {
    try {
        return new URL(url).href;
    } catch(e) {
        return undefined;
    }
}

// generate an absolute URL from a relative URL
// example: getAbsoluteURL('foo/bar/toto') return http://cds.unistra.fr/AL/foo/bar/toto if executed from page http://cds.unistra.fr/AL/
Utils.getAbsoluteURL = function (url) {
    var a = document.createElement('a')
    a.href = url

    return a.href
}

// generate a valid v4 UUID
Utils.uuidv4 = function () {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
        var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8)
        return v.toString(16)
    })
}

/**
 * @function
 * @description Deep clone a class instance.
 * @param {object} instance The class instance you want to clone.
 * @returns {object} A new cloned instance.
 */
Utils.clone = function (instance) {
    return Object.assign(
        Object.create(
            // Set the prototype of the new object to the prototype of the instance.
            // Used to allow new object behave like class instance.
            Object.getPrototypeOf(instance),
        ),
        // Prevent shallow copies of nested structures like arrays, etc
        JSON.parse(JSON.stringify(instance)),
    )
}

Utils.getDroppedFilesHandler = function (ev) {
    // Prevent default behavior (Prevent file from being opened)
    ev.preventDefault()

    let items
    if (ev.dataTransfer.items) {
        // Use DataTransferItemList interface to access the file(s)
        items = [...ev.dataTransfer.items]
    } else {
        // Use DataTransfer interface to access the file(s)
        items = [...ev.dataTransfer.files]
    }

    const files = items.filter((item) => {
        // If dropped items aren't files, reject them
        return item.kind === 'file'
    })
        .map((item) => item.getAsFile())

    return files
}

Utils.dragOverHandler = function (ev) {
    // Prevent default behavior (Prevent file from being opened)
    ev.preventDefault()
}

Utils.requestCORSIfNotSameOrigin = function (url) {
    return (new URL(url, window.location.href)).origin !== window.location.origin
}

// Check the protocol, for http ones, use a CORS compatible proxy
Utils.handleCORSNotSameOrigin = function (url) {
    if (Utils.requestCORSIfNotSameOrigin(url)) {
        // http(s) protocols and not in localhost
        let proxiedUrl = new URL(JSONP_PROXY)
        proxiedUrl.searchParams.append('url', url)

        url = proxiedUrl
    }

    return url
}

Utils.deepCopy = function (orig) {
    return Object.assign(Object.create(Object.getPrototypeOf(orig)), orig)
}

Utils.download = function(url, name = undefined) {
    const a = document.createElement('a')
    a.href = url
    if (name) {
        a.download = name;
    }
    a.click()
}

// Copy some text to the clipboard
// This provides an alternative when the window context is not secure (e.g. http access)
// Code copied from: https://stackoverflow.com/questions/51805395/navigator-clipboard-is-undefined
Utils.copy2Clipboard = async function(textToCopy) {
    // Navigator clipboard api needs a secure context (https)
    if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(textToCopy);
    } else {
        // Use the 'out of viewport hidden text area' trick
        const textArea = document.createElement("textarea");
        textArea.value = textToCopy;

        // Move textarea out of the viewport so it's not visible
        textArea.style.position = "absolute";
        textArea.style.opacity = "0";

        document.body.prepend(textArea);
        textArea.select();

        try {
            // execCommand is deprecated but there seems to be no other
            // alternative and is still used by a lot of applications
            // See: https://stackoverflow.com/questions/60581285/execcommand-is-now-obsolete-whats-the-alternative    
            document.execCommand('copy');
        } catch (error) {
            console.error(error);
        } finally {
            textArea.remove();
        }
    }
}

Utils.measureTime = function(msg, method) {
    let startTime = performance.now()
    let output = method()
    let deltaTime = performance.now() - startTime;
    console.log(msg, deltaTime);

    return output;
};
