/**
 * Co-ordinate conversion resources.
 *
 * @author j433866 [j433866@gmail.com]
 * @copyright Crown Copyright 2019
 * @license Apache-2.0
 */

import OperationError from "../errors/OperationError.mjs";
import geohash from "ngeohash";
/*
Currently unable to update to geodesy v2 as we cannot load .js modules into a .mjs file.
When we do update, imports will look like this:

import LatLonEllipsoidal from "geodesy/latlon-ellipsoidal.js";
import Mgrs from "geodesy/mgrs.js";
import OsGridRef from "geodesy/osgridref.js";
import Utm from "geodesy/utm.js";
*/
import geodesy from "geodesy";
const LatLonEllipsoidal = geodesy.LatLonEllipsoidal,
    Mgrs = geodesy.Mgrs,
    OsGridRef = geodesy.OsGridRef,
    Utm = geodesy.Utm;

/**
 * Co-ordinate formats
 */
export const FORMATS = [
    "Degrees Minutes Seconds",
    "Degrees Decimal Minutes",
    "Decimal Degrees",
    "Geohash",
    "Military Grid Reference System",
    "Ordnance Survey National Grid",
    "Universal Transverse Mercator"
];

/**
 * Formats that should be passed to the conversion module as-is
 */
const NO_CHANGE = [
    "Geohash",
    "Military Grid Reference System",
    "Ordnance Survey National Grid",
    "Universal Transverse Mercator",
];

/**
 * Convert a given latitude and longitude into a different format.
 *
 * @param {string} input - Input string to be converted
 * @param {string} inFormat - Format of the input coordinates
 * @param {string} inDelim - The delimiter splitting the lat/long of the input
 * @param {string} outFormat - Format to convert to
 * @param {string} outDelim - The delimiter to separate the output with
 * @param {string} includeDir - Whether or not to include the compass direction in the output
 * @param {number} precision - Precision of the result
 * @returns {string} A formatted string of the converted co-ordinates
 */
export function convertCoordinates (input, inFormat, inDelim, outFormat, outDelim, includeDir, precision) {
    let isPair = false,
        split,
        latlon,
        convLat,
        convLon,
        conv,
        hash,
        utm,
        mgrs,
        osng,
        splitLat,
        splitLong,
        lat,
        lon;

    // Can't have a precision less than 0!
    if (precision < 0) {
        precision = 0;
    }

    if (inDelim === "Auto") {
        // Try to detect a delimiter in the input.
        inDelim = findDelim(input);
        if (inDelim === null) {
            throw new OperationError("Unable to detect the input delimiter automatically.");
        }
    } else if (!inDelim.includes("Direction")) {
        // Convert the delimiter argument value to the actual character
        inDelim = realDelim(inDelim);
    }

    if (inFormat === "Auto") {
        // Try to detect the format of the input data
        inFormat = findFormat(input, inDelim);
        if (inFormat === null) {
            throw new OperationError("Unable to detect the input format automatically.");
        }
    }

    // Convert the output delimiter argument to the real character
    outDelim = realDelim(outDelim);

    if (!NO_CHANGE.includes(inFormat)) {
        if (inDelim.includes("Direction")) {
            // Split on directions
            split = input.split(/[NnEeSsWw]/g);
            if (split[0] === "") {
                // Remove first element if direction preceding
                split = split.slice(1);
            }
        } else {
            split = input.split(inDelim);
        }
        // Replace any co-ordinate symbols with spaces so we can split on them later
        for (let i = 0; i < split.length; i++) {
            split[i] = split[i].replace(/[°˝´'"]/g, " ");
        }
        if (split.length > 1) {
            isPair = true;
        }
    } else {
        // Remove any delimiters from the input
        input = input.replace(inDelim, "");
        isPair = true;
    }

    // Conversions from the input format into a geodesy latlon object
    switch (inFormat) {
        case "Geohash":
            hash = geohash.decode(input.replace(/[^A-Za-z0-9]/g, ""));
            latlon = new LatLonEllipsoidal(hash.latitude, hash.longitude);
            break;
        case "Military Grid Reference System":
            utm = Mgrs.parse(input.replace(/[^A-Za-z0-9]/g, "")).toUtm();
            latlon = utm.toLatLonE();
            break;
        case "Ordnance Survey National Grid":
            osng = OsGridRef.parse(input.replace(/[^A-Za-z0-9]/g, ""));
            latlon = OsGridRef.osGridToLatLon(osng);
            break;
        case "Universal Transverse Mercator":
            // Geodesy needs a space between the first 2 digits and the next letter
            if (/^[\d]{2}[A-Za-z]/.test(input)) {
                input = input.slice(0, 2) + " " + input.slice(2);
            }
            utm = Utm.parse(input);
            latlon = utm.toLatLonE();
            break;
        case "Degrees Minutes Seconds":
            if (isPair) {
                // Split up the lat/long into degrees / minutes / seconds values
                splitLat = splitInput(split[0]);
                splitLong = splitInput(split[1]);

                if (splitLat.length >= 3 && splitLong.length >= 3) {
                    lat = convDMSToDD(splitLat[0], splitLat[1], splitLat[2], 10);
                    lon = convDMSToDD(splitLong[0], splitLong[1], splitLong[2], 10);
                    latlon = new LatLonEllipsoidal(lat.degrees, lon.degrees);
                } else {
                    throw new OperationError("Invalid co-ordinate format for Degrees Minutes Seconds");
                }
            } else {
                // Not a pair, so only try to convert one set of co-ordinates
                splitLat = splitInput(split[0]);
                if (splitLat.length >= 3) {
                    lat = convDMSToDD(splitLat[0], splitLat[1], splitLat[2]);
                    latlon = new LatLonEllipsoidal(lat.degrees, lat.degrees);
                } else {
                    throw new OperationError("Invalid co-ordinate format for Degrees Minutes Seconds");
                }
            }
            break;
        case "Degrees Decimal Minutes":
            if (isPair) {
                splitLat = splitInput(split[0]);
                splitLong = splitInput(split[1]);
                if (splitLat.length !== 2 || splitLong.length !== 2) {
                    throw new OperationError("Invalid co-ordinate format for Degrees Decimal Minutes.");
                }
                // Convert to decimal degrees, and then convert to a geodesy object
                lat = convDDMToDD(splitLat[0], splitLat[1], 10);
                lon = convDDMToDD(splitLong[0], splitLong[1], 10);
                latlon = new LatLonEllipsoidal(lat.degrees, lon.degrees);
            } else {
                // Not a pair, so only try to convert one set of co-ordinates
                splitLat = splitInput(input);
                if (splitLat.length !== 2) {
                    throw new OperationError("Invalid co-ordinate format for Degrees Decimal Minutes.");
                }
                lat = convDDMToDD(splitLat[0], splitLat[1], 10);
                latlon = new LatLonEllipsoidal(lat.degrees, lat.degrees);
            }
            break;
        case "Decimal Degrees":
            if (isPair) {
                splitLat =  splitInput(split[0]);
                splitLong = splitInput(split[1]);
                if (splitLat.length !== 1 || splitLong.length !== 1) {
                    throw new OperationError("Invalid co-ordinate format for Decimal Degrees.");
                }
                latlon = new LatLonEllipsoidal(splitLat[0], splitLong[0]);
            } else {
                // Not a pair, so only try to convert one set of co-ordinates
                splitLat = splitInput(split[0]);
                if (splitLat.length !== 1) {
                    throw new OperationError("Invalid co-ordinate format for Decimal Degrees.");
                }
                latlon = new LatLonEllipsoidal(splitLat[0], splitLat[0]);
            }
            break;
        default:
            throw new OperationError(`Unknown input format '${inFormat}'`);
    }

    // Everything is now a geodesy latlon object
    // These store the latitude and longitude as decimal
    if (inFormat.includes("Degrees")) {
        // If the input string contains directions, we need to check if they're S or W.
        // If either of the directions are, we should make the decimal value negative
        const dirs = input.toUpperCase().match(/[NESW]/g);
        if (dirs && dirs.length >= 1) {
            // Make positive lat/lon values with S/W directions into negative values
            if (dirs[0] === "S" || dirs[0] === "W" && latlon.lat > 0) {
                latlon.lat = -latlon.lat;
            }
            if (dirs.length >= 2) {
                if (dirs[1] === "S" || dirs[1] === "W" && latlon.lon > 0) {
                    latlon.lon = -latlon.lon;
                }
            }
        }
    }

    // Try to find the compass directions of the lat and long
    const [latDir, longDir] = findDirs(latlon.lat + "," + latlon.lon, ",");

    // Output conversions for each output format
    switch (outFormat) {
        case "Decimal Degrees":
            // We could use the built in latlon.toString(),
            // but this makes adjusting the output harder
            lat = convDDToDD(latlon.lat, precision);
            lon = convDDToDD(latlon.lon, precision);
            convLat = lat.string;
            convLon = lon.string;
            break;
        case "Degrees Decimal Minutes":
            lat = convDDToDDM(latlon.lat, precision);
            lon = convDDToDDM(latlon.lon, precision);
            convLat = lat.string;
            convLon = lon.string;
            break;
        case "Degrees Minutes Seconds":
            lat = convDDToDMS(latlon.lat, precision);
            lon = convDDToDMS(latlon.lon, precision);
            convLat = lat.string;
            convLon = lon.string;
            break;
        case "Geohash":
            convLat = geohash.encode(latlon.lat, latlon.lon, precision);
            break;
        case "Military Grid Reference System":
            utm = latlon.toUtm();
            mgrs = utm.toMgrs();
            // MGRS wants a precision that's an even number between 2 and 10
            if (precision % 2 !== 0) {
                precision = precision + 1;
            }
            if (precision > 10) {
                precision = 10;
            }
            convLat = mgrs.toString(precision);
            break;
        case "Ordnance Survey National Grid":
            osng = OsGridRef.latLonToOsGrid(latlon);
            if (osng.toString() === "") {
                throw new OperationError("Could not convert co-ordinates to OS National Grid. Are the co-ordinates in range?");
            }
            // OSNG wants a precision that's an even number between 2 and 10
            if (precision % 2 !== 0) {
                precision = precision + 1;
            }
            if (precision > 10) {
                precision = 10;
            }
            convLat = osng.toString(precision);
            break;
        case "Universal Transverse Mercator":
            utm = latlon.toUtm();
            convLat = utm.toString(precision);
            break;
    }

    if (convLat === undefined) {
        throw new OperationError("Error converting co-ordinates.");
    }

    if (outFormat.includes("Degrees")) {
        // Format DD/DDM/DMS for output
        // If we're outputting a compass direction, remove the negative sign
        if (latDir === "S" && includeDir !== "None") {
            convLat = convLat.replace("-", "");
        }
        if (longDir === "W" && includeDir !== "None") {
            convLon = convLon.replace("-", "");
        }

        let outConv = "";
        if (includeDir === "Before") {
            outConv += latDir + " ";
        }

        outConv += convLat;
        if (includeDir === "After") {
            outConv += " " + latDir;
        }
        outConv += outDelim;
        if (isPair) {
            if (includeDir === "Before") {
                outConv += longDir + " ";
            }
            outConv += convLon;
            if (includeDir === "After") {
                outConv += " " + longDir;
            }
            outConv += outDelim;
        }
        conv = outConv;
    } else {
        conv = convLat + outDelim;
    }

    return conv;
}

/**
 * Split up the input using a space or degrees signs, and sanitise the result
 *
 * @param {string} input - The input data to be split
 * @returns {number[]} An array of the different items in the string, stored as floats
 */
function splitInput (input) {
    const split = [];

    input.split(/\s+/).forEach(item => {
        // Remove any character that isn't a digit, decimal point or negative sign
        item = item.replace(/[^0-9.-]/g, "");
        if (item.length > 0) {
            // Turn the item into a float
            split.push(parseFloat(item));
        }
    });
    return split;
}

/**
 * Convert Degrees Minutes Seconds to Decimal Degrees
 *
 * @param {number} degrees - The degrees of the input co-ordinates
 * @param {number} minutes - The minutes of the input co-ordinates
 * @param {number} seconds - The seconds of the input co-ordinates
 * @param {number} precision - The precision the result should be rounded to
 * @returns {{string: string, degrees: number}} An object containing the raw converted value (obj.degrees), and a formatted string version (obj.string)
 */
function convDMSToDD (degrees, minutes, seconds, precision) {
    const absDegrees = Math.abs(degrees);
    let conv = absDegrees + (minutes / 60) + (seconds / 3600);
    let outString = round(conv, precision) + "°";
    if (isNegativeZero(degrees) || degrees < 0) {
        conv = -conv;
        outString = "-" + outString;
    }
    return {
        "degrees": conv,
        "string": outString
    };
}

/**
 * Convert Decimal Degrees Minutes to Decimal Degrees
 *
 * @param {number} degrees - The input degrees to be converted
 * @param {number} minutes - The input minutes to be converted
 * @param {number} precision - The precision which the result should be rounded to
 * @returns {{string: string, degrees: number}} An object containing the raw converted value (obj.degrees), and a formatted string version (obj.string)
 */
function convDDMToDD (degrees, minutes, precision) {
    const absDegrees = Math.abs(degrees);
    let conv = absDegrees + minutes / 60;
    let outString = round(conv, precision) + "°";
    if (isNegativeZero(degrees) || degrees < 0) {
        conv = -conv;
        outString = "-" + outString;
    }
    return {
        "degrees": conv,
        "string": outString
    };
}

/**
 * Convert Decimal Degrees to Decimal Degrees
 *
 * Doesn't affect the input, just puts it into an object
 * @param {number} degrees - The input degrees to be converted
 * @param {number} precision - The precision which the result should be rounded to
 * @returns {{string: string, degrees: number}} An object containing the raw converted value (obj.degrees), and a formatted string version (obj.string)
 */
function convDDToDD (degrees, precision) {
    return {
        "degrees": degrees,
        "string": round(degrees, precision) + "°"
    };
}

/**
 * Convert Decimal Degrees to Degrees Minutes Seconds
 *
 * @param {number} decDegrees - The input data to be converted
 * @param {number} precision - The precision which the result should be rounded to
 * @returns {{string: string, degrees: number, minutes: number, seconds: number}} An object containing the raw converted value as separate numbers (.degrees, .minutes, .seconds), and a formatted string version (obj.string)
 */
function convDDToDMS (decDegrees, precision) {
    const absDegrees = Math.abs(decDegrees);
    let degrees = Math.floor(absDegrees);
    const minutes = Math.floor(60 * (absDegrees - degrees)),
        seconds = round(3600 * (absDegrees - degrees) - 60 * minutes, precision);
    let outString = degrees + "° " + minutes + "' " + seconds + "\"";
    if (isNegativeZero(decDegrees) || decDegrees < 0) {
        degrees = -degrees;
        outString = "-" + outString;
    }
    return {
        "degrees": degrees,
        "minutes": minutes,
        "seconds": seconds,
        "string": outString
    };
}

/**
 * Convert Decimal Degrees to Degrees Decimal Minutes
 *
 * @param {number} decDegrees - The input degrees to be converted
 * @param {number} precision - The precision the input data should be rounded to
 * @returns {{string: string, degrees: number, minutes: number}} An object containing the raw converted value as separate numbers (.degrees, .minutes), and a formatted string version (obj.string)
 */
function convDDToDDM (decDegrees, precision) {
    const absDegrees = Math.abs(decDegrees);
    let degrees = Math.floor(absDegrees);
    const minutes = absDegrees - degrees,
        decMinutes = round(minutes * 60, precision);
    let outString = degrees + "° " + decMinutes + "'";
    if (decDegrees < 0 || isNegativeZero(decDegrees)) {
        degrees = -degrees;
        outString = "-" + outString;
    }

    return {
        "degrees": degrees,
        "minutes": decMinutes,
        "string": outString,
    };
}

/**
 * Finds and returns the compass directions in an input string
 *
 * @param {string} input - The input co-ordinates containing the direction
 * @param {string} delim - The delimiter separating latitide and longitude
 * @returns {string[]} String array containing the latitude and longitude directions
 */
export function findDirs(input, delim) {
    const upperInput = input.toUpperCase();
    const dirExp = new RegExp(/[NESW]/g);

    const dirs = upperInput.match(dirExp);

    if (dirs) {
        // If there's actually compass directions
        // in the input, use these to work out the direction
        if (dirs.length <= 2 && dirs.length >= 1) {
            return dirs.length === 2 ? [dirs[0], dirs[1]] : [dirs[0], ""];
        }
    }

    // Nothing was returned, so guess the directions
    let lat = upperInput,
        long,
        latDir = "",
        longDir = "";
    if (!delim.includes("Direction")) {
        if (upperInput.includes(delim)) {
            const split = upperInput.split(delim);
            if (split.length >= 1) {
                if (split[0] !== "") {
                    lat = split[0];
                }
                if (split.length >= 2 && split[1] !== "") {
                    long = split[1];
                }
            }
        }
    } else {
        const split = upperInput.split(dirExp);
        if (split.length > 1) {
            lat = split[0] === "" ? split[1] : split[0];
            if (split.length > 2 && split[2] !== "") {
                long = split[2];
            }
        }
    }

    if (lat) {
        lat = parseFloat(lat);
        latDir = lat < 0 ? "S" : "N";
    }

    if (long) {
        long = parseFloat(long);
        longDir = long < 0 ? "W" : "E";
    }

    return [latDir, longDir];
}

/**
 * Detects the co-ordinate format of the input data
 *
 * @param {string} input - The input data whose format we need to detect
 * @param {string} delim - The delimiter separating the data in input
 * @returns {string} The input format
 */
export function findFormat (input, delim) {
    let testData;
    const mgrsPattern = new RegExp(/^[0-9]{2}\s?[C-HJ-NP-X]{1}\s?[A-HJ-NP-Z][A-HJ-NP-V]\s?[0-9\s]+/),
        osngPattern = new RegExp(/^[A-HJ-Z]{2}\s+[0-9\s]+$/),
        geohashPattern = new RegExp(/^[0123456789BCDEFGHJKMNPQRSTUVWXYZ]+$/),
        utmPattern = new RegExp(/^[0-9]{2}\s?[C-HJ-NP-X]\s[0-9.]+\s?[0-9.]+$/),
        degPattern = new RegExp(/[°'"]/g);

    input = input.trim();

    if (delim !== null && delim.includes("Direction")) {
        const split = input.split(/[NnEeSsWw]/);
        if (split.length > 1) {
            testData = split[0] === "" ? split[1] : split[0];
        }
    } else if (delim !== null && delim !== "") {
        if (input.includes(delim)) {
            const split = input.split(delim);
            if (split.length > 1) {
                testData = split[0] === "" ? split[1] : split[0];
            }
        } else {
            testData = input;
        }
    }

    // Test non-degrees formats
    if (!degPattern.test(input)) {
        const filteredInput = input.toUpperCase().replace(delim, "");

        if (utmPattern.test(filteredInput)) {
            return "Universal Transverse Mercator";
        }
        if (mgrsPattern.test(filteredInput)) {
            return "Military Grid Reference System";
        }
        if (osngPattern.test(filteredInput)) {
            return "Ordnance Survey National Grid";
        }
        if (geohashPattern.test(filteredInput)) {
            return "Geohash";
        }
    }

    // Test DMS/DDM/DD formats
    if (testData !== undefined) {
        const split = splitInput(testData);
        switch (split.length) {
            case 3:
                return "Degrees Minutes Seconds";
            case 2:
                return "Degrees Decimal Minutes";
            case 1:
                return "Decimal Degrees";
        }
    }
    return null;
}

/**
 * Automatically find the delimeter type from the given input
 *
 * @param {string} input
 * @returns {string} Delimiter type
 */
export function findDelim (input) {
    input = input.trim();
    const delims = [",", ";", ":"];
    const testDir = input.match(/[NnEeSsWw]/g);
    if (testDir !== null && testDir.length > 0 && testDir.length < 3) {
        // Possibly contains a direction
        const splitInput = input.split(/[NnEeSsWw]/);
        if (splitInput.length <= 3 && splitInput.length > 0) {
            // If there's 3 splits (one should be empty), then assume we have directions
            if (splitInput[0] === "") {
                return "Direction Preceding";
            } else if (splitInput[splitInput.length - 1] === "") {
                return "Direction Following";
            }
        }
    }

    // Loop through the standard delimiters, and try to find them in the input
    for (let i = 0; i < delims.length; i++) {
        const delim = delims[i];
        if (input.includes(delim)) {
            const splitInput = input.split(delim);
            if (splitInput.length <= 3 && splitInput.length > 0) {
                // Don't want to try and convert more than 2 co-ordinates
                return delim;
            }
        }
    }
    return null;
}

/**
 * Gets the real string for a delimiter name.
 *
 * @param {string} delim The delimiter to be matched
 * @returns {string}
 */
export function realDelim (delim) {
    return {
        "Auto":         "Auto",
        "Space":        " ",
        "\\n":          "\n",
        "Comma":        ",",
        "Semi-colon":   ";",
        "Colon":        ":"
    }[delim];
}

/**
 * Returns true if a zero is negative
 *
 * @param {number} zero
 * @returns {boolean}
 */
function isNegativeZero(zero) {
    return zero === 0 && (1/zero < 0);
}

/**
 * Rounds a number to a specified number of decimal places
 *
 * @param {number} input - The number to be rounded
 * @param {precision} precision - The number of decimal places the number should be rounded to
 * @returns {number}
 */
function round(input, precision) {
    precision = Math.pow(10, precision);
    return Math.round(input * precision) / precision;
}
