/**
 * Created by keithpk on 12/2/16.
 */

import { isNothing, Nothing, Opt } from "@jet/environment/types/optional";
import { isDefinedNonNullNonEmpty, isNullOrEmpty } from "./server-data";

export type Query = {
    [key: string]: string | undefined;
};

export type URLComponent = "protocol" | "username" | "password" | "host" | "port" | "pathname" | "query" | "hash";

const protocolRegex = /^([a-z][a-z0-9.+-]*:)(\/\/)?([\S\s]*)/i;
const queryParamRegex = /([^=?&]+)=?([^&]*)/g;
const componentOrder: URLComponent[] = ["hash", "query", "pathname", "host"];

type URLSplitStyle = "prefix" | "suffix";

type URLSplitResult = {
    result?: string;
    remainder: string;
};

function splitUrlComponent(input: string, marker: string, style: URLSplitStyle): URLSplitResult {
    const index = input.indexOf(marker);
    let result;
    let remainder = input;
    if (index !== -1) {
        const prefix = input.slice(0, index);
        const suffix = input.slice(index + marker.length, input.length);

        if (style === "prefix") {
            result = prefix;
            remainder = suffix;
        } else {
            result = suffix;
            remainder = prefix;
        }
    }

    // log("Token: " + marker + " String: " + input, " Result: " + result + " Remainder: " + remainder)

    return {
        result: result,
        remainder: remainder,
    };
}

export class URL {
    protocol?: Opt<string>;
    username: string;
    password: string;
    host?: Opt<string>;
    port: string;
    pathname?: Opt<string>;
    query?: Query = {};
    hash?: string;

    constructor(url?: string) {
        if (isNullOrEmpty(url)) {
            return;
        }

        // Split the protocol from the rest of the urls
        let remainder = url;
        const match = protocolRegex.exec(url);
        if (match != null) {
            // Pull out the protocol
            let protocol = match[1];
            if (protocol) {
                protocol = protocol.split(":")[0];
            }

            this.protocol = protocol;

            // Save the remainder
            remainder = match[3];
        }

        // Then match each component in a specific order
        let parse: URLSplitResult = { remainder: remainder, result: undefined };
        for (const component of componentOrder) {
            if (!parse.remainder) {
                break;
            }

            switch (component) {
                case "hash": {
                    parse = splitUrlComponent(parse.remainder, "#", "suffix");
                    this.hash = parse.result;
                    break;
                }
                case "query": {
                    parse = splitUrlComponent(parse.remainder, "?", "suffix");
                    if (isDefinedNonNullNonEmpty(parse.result)) {
                        this.query = URL.queryFromString(parse.result);
                    }
                    break;
                }
                case "pathname": {
                    parse = splitUrlComponent(parse.remainder, "/", "suffix");

                    if (isDefinedNonNullNonEmpty(parse.result)) {
                        // Replace the initial /, since paths require it
                        this.pathname = "/" + parse.result;
                    }
                    break;
                }
                case "host": {
                    if (parse.remainder) {
                        const authorityParse = splitUrlComponent(parse.remainder, "@", "prefix");
                        const userInfo = authorityParse.result;
                        const hostPort = authorityParse.remainder;
                        if (isDefinedNonNullNonEmpty(userInfo)) {
                            const userInfoSplit = userInfo.split(":");
                            this.username = decodeURIComponent(userInfoSplit[0]);
                            this.password = decodeURIComponent(userInfoSplit[1]);
                        }

                        if (hostPort) {
                            const hostPortSplit = hostPort.split(":");
                            this.host = hostPortSplit[0];
                            this.port = hostPortSplit[1];
                        }
                    }
                    break;
                }
                default: {
                    throw new Error("Unhandled case!");
                }
            }
        }
    }

    set(component: URLComponent, value: string | Query): URL {
        if (isNullOrEmpty(value)) {
            return this;
        }

        if (component === "query") {
            if (typeof value === "string") {
                value = URL.queryFromString(value);
            }
        }

        switch (component) {
            // Exhaustive match to make sure TS property minifiers and other
            // transformer plugins do not break this code.
            case "protocol":
                this.protocol = value as string;
                break;
            case "username":
                this.username = value as string;
                break;
            case "password":
                this.password = value as string;
                break;
            case "port":
                this.port = value as string;
                break;
            case "pathname":
                this.pathname = value as string;
                break;
            case "query":
                this.query = value as Query;
                break;
            case "hash":
                this.hash = value as string;
                break;
            default:
                // The fallback for component which is not a property of URL object.
                this[component] = value as string;
                break;
        }
        return this;
    }

    private get(component: URLComponent): string | Query | Nothing {
        switch (component) {
            // Exhaustive match to make sure TS property minifiers and other
            // transformer plugins do not break this code.
            case "protocol":
                return this.protocol;
            case "username":
                return this.username;
            case "password":
                return this.password;
            case "port":
                return this.port;
            case "pathname":
                return this.pathname;
            case "query":
                return this.query;
            case "hash":
                return this.hash;
            default:
                // The fallback for component which is not a property of URL object.
                return this[component];
        }
    }

    append(component: URLComponent, value: string | Query): URL {
        const existingValue = this.get(component);
        let newValue;

        if (component === "query") {
            if (typeof value === "string") {
                value = URL.queryFromString(value);
            }

            if (typeof existingValue === "string") {
                newValue = { existingValue, ...value };
            } else {
                newValue = { ...existingValue, ...value };
            }
        } else {
            let existingValueString = existingValue as string;

            if (!existingValueString) {
                existingValueString = "";
            }

            let newValueString = existingValueString;

            if (component === "pathname") {
                const pathLength = existingValueString.length;
                if (!pathLength || existingValueString[pathLength - 1] !== "/") {
                    newValueString += "/";
                }
            }

            // eslint-disable-next-line @typescript-eslint/restrict-plus-operands, @typescript-eslint/no-base-to-string
            newValueString += value;
            newValue = newValueString;
        }

        return this.set(component, newValue);
    }

    param(key: string, value?: string): URL {
        if (!key) {
            return this;
        }
        if (this.query == null) {
            this.query = {};
        }
        this.query[key] = value;
        return this;
    }

    removeParam(key: string): URL {
        if (!key || this.query == null) {
            return this;
        }
        if (this.query[key] !== undefined) {
            delete this.query[key];
        }
        return this;
    }

    /**
     * Push a new string value onto the path for this url
     * @returns URL this object with the updated path.
     */
    path(value: string): URL {
        return this.append("pathname", value);
    }

    pathExtension(): Opt<string> {
        // Extract path extension if one exists
        if (isNothing(this.pathname)) {
            return null;
        }

        const lastFilenameComponents = this.pathname
            .split("/")
            .filter((item) => item.length > 0) // Remove any double or trailing slashes
            .pop()
            ?.split(".");
        if (lastFilenameComponents === undefined) {
            return null;
        }
        if (
            lastFilenameComponents.filter((part) => {
                return part !== "";
            }).length < 2 // Remove any empty parts (e.g. .ssh_config -> ["ssh_config"])
        ) {
            return null;
        }

        return lastFilenameComponents.pop();
    }

    /**
     * Returns the path components of the URL
     * @returns An array of non-empty path components from `urls`.
     */
    pathComponents(): string[] {
        if (isNullOrEmpty(this.pathname)) {
            return [];
        }

        return this.pathname.split("/").filter((component) => component.length > 0);
    }

    /**
     * Returns the last path component from this url, updating the url to not include this path component
     * @returns String the last path component from this url.
     */
    popPathComponent(): string | null {
        if (isNullOrEmpty(this.pathname)) {
            return null;
        }

        const lastPathComponent = this.pathname.slice(this.pathname.lastIndexOf("/") + 1);

        if (lastPathComponent.length === 0) {
            return null;
        }

        this.pathname = this.pathname.slice(0, this.pathname.lastIndexOf("/"));

        return lastPathComponent;
    }

    /**
     * Same as toString
     *
     * @returns {string} A string representation of the URL
     */
    build(): string {
        return this.toString();
    }

    /**
     * Converts the URL to a string
     *
     * @returns {string} A string representation of the URL
     */
    toString(): string {
        let url = "";

        if (isDefinedNonNullNonEmpty(this.protocol)) {
            url += this.protocol + "://";
        }

        if (this.username) {
            url += encodeURIComponent(this.username);

            if (this.password) {
                url += ":" + encodeURIComponent(this.password);
            }

            url += "@";
        }

        if (isDefinedNonNullNonEmpty(this.host)) {
            url += this.host;

            if (this.port) {
                url += ":" + this.port;
            }
        }

        if (isDefinedNonNullNonEmpty(this.pathname)) {
            url += this.pathname;
            /// Trim off trailing path separators when we have a valid path
            /// We don't do this unless pathname has elements otherwise we will trim the `://`
            if (url.endsWith("/") && this.pathname.length > 0) {
                url = url.slice(0, -1);
            }
        }

        if (this.query != null && Object.keys(this.query).length > 0) {
            url += "?" + URL.toQueryString(this.query);
        }

        if (isDefinedNonNullNonEmpty(this.hash)) {
            url += "#" + this.hash;
        }

        return url;
    }

    // ----------------
    // Static API
    // ----------------

    /**
     * Converts a string into a query dictionary
     * @param query The string to parse
     * @returns The query dictionary containing the key-value pairs in the query string
     */
    static queryFromString(query: string): Query {
        const result = {};

        let parseResult = queryParamRegex.exec(query);
        while (parseResult != null) {
            const key = decodeURIComponent(parseResult[1]);
            const value = decodeURIComponent(parseResult[2]);
            result[key] = value;
            parseResult = queryParamRegex.exec(query);
        }

        return result;
    }

    /**
     * Converts a query dictionary into a query string
     *
     * @param query The query dictionary
     * @returns {string} The string representation of the query dictionary
     */
    static toQueryString(query: Query) {
        let queryString = "";

        let first = true;
        for (const key of Object.keys(query)) {
            if (!first) {
                queryString += "&";
            }
            first = false;

            queryString += encodeURIComponent(key);

            const value = query[key];
            if (isDefinedNonNullNonEmpty(value) && value.length) {
                queryString += "=" + encodeURIComponent(value);
            }
        }

        return queryString;
    }

    /**
     * Convenience method to instantiate a URL from a string
     * @param url The URL string to parse
     * @returns {URL} The new URL object representing the URL
     */
    static from(url: string): URL {
        return new URL(url);
    }

    /**
     * Convenience method to instantiate a URL from numerous (optional) components
     * @param protocol The protocol type
     * @param host The host name
     * @param path The path
     * @param query The query
     * @param hash The hash
     * @returns {URL} The new URL object representing the URL
     */
    static fromComponents(
        protocol?: Opt<string>,
        host?: Opt<string>,
        path?: Opt<string>,
        query?: Query,
        hash?: string,
    ): URL {
        const url = new URL();
        url.protocol = protocol;
        url.host = host;
        url.pathname = path;
        url.query = query;
        url.hash = hash;
        return url;
    }
}
