/* eslint-disable max-classes-per-file */
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

'use strict';

import * as pathImport from 'path';
import { CharCode } from './charCode';

const isWindows = /^win/.test(process.platform);

const _schemePattern = /^\w[\w\d+.-]*$/;
const _singleSlashStart = /^\//;
const _doubleSlashStart = /^\/\//;

const _empty = '';
const _slash = '/';
const _regexp = /^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;

const _pathSepMarker = isWindows ? 1 : undefined;

let _throwOnMissingSchema = true;

/**
 * @internal
 */
export function setUriThrowOnMissingScheme(value: boolean): boolean {
    const old = _throwOnMissingSchema;
    _throwOnMissingSchema = value;
    return old;
}

function _validateUri(ret: URI, _strict?: boolean): void {
    // scheme, must be set
    // if (!ret.scheme) {
    //     // if (_strict || _throwOnMissingSchema) {
    //     //     throw new Error(`[UriError]: Scheme is missing: {scheme: "", authority: "${ret.authority}", path: "${ret.path}", query: "${ret.query}", fragment: "${ret.fragment}"}`);
    //     // } else {
    //     console.warn(`[UriError]: Scheme is missing: {scheme: "", authority: "${ret.authority}", path: "${ret.path}", query: "${ret.query}", fragment: "${ret.fragment}"}`);
    //     // }
    // }

    // scheme, https://tools.ietf.org/html/rfc3986#section-3.1
    // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
    if (ret.scheme && !_schemePattern.test(ret.scheme)) {
        throw new Error('[UriError]: Scheme contains illegal characters.');
    }

    // path, http://tools.ietf.org/html/rfc3986#section-3.3
    // If a URI contains an authority component, then the path component
    // must either be empty or begin with a slash ("/") character.  If a URI
    // does not contain an authority component, then the path cannot begin
    // with two slash characters ("//").
    if (ret.path) {
        if (ret.authority) {
            if (!_singleSlashStart.test(ret.path)) {
                throw new Error(
                    '[UriError]: If a URI contains an authority component, then the path component must either be empty or begin with a slash ("/") character',
                );
            }
        } else if (_doubleSlashStart.test(ret.path)) {
            throw new Error(
                '[UriError]: If a URI does not contain an authority component, then the path cannot begin with two slash characters ("//")',
            );
        }
    }
}

// for a while we allowed uris *without* schemes and this is the migration
// for them, e.g. an uri without scheme and without strict-mode warns and falls
// back to the file-scheme. that should cause the least carnage and still be a
// clear warning
function _schemeFix(scheme: string, _strict: boolean): string {
    if (_strict || _throwOnMissingSchema) {
        return scheme || _empty;
    }
    if (!scheme) {
        console.trace('BAD uri lacks scheme, falling back to file-scheme.');
        scheme = 'file';
    }
    return scheme;
}

// implements a bit of https://tools.ietf.org/html/rfc3986#section-5
function _referenceResolution(scheme: string, path: string): string {
    // the slash-character is our 'default base' as we don't
    // support constructing URIs relative to other URIs. This
    // also means that we alter and potentially break paths.
    // see https://tools.ietf.org/html/rfc3986#section-5.1.4
    switch (scheme) {
        case 'https':
        case 'http':
        case 'file':
            if (!path) {
                path = _slash;
            } else if (path[0] !== _slash) {
                path = _slash + path;
            }
            break;
        default:
            break;
    }
    return path;
}

/**
 * Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986.
 * This class is a simple parser which creates the basic component parts
 * (http://tools.ietf.org/html/rfc3986#section-3) with minimal validation
 * and encoding.
 *
 *       foo://example.com:8042/over/there?name=ferret#nose
 *       \_/   \______________/\_________/ \_________/ \__/
 *        |           |            |            |        |
 *     scheme     authority       path        query   fragment
 *        |   _____________________|__
 *       / \ /                        \
 *       urn:example:animal:ferret:nose
 */

export class URI implements UriComponents {
    static isUri(thing: unknown): thing is URI {
        if (thing instanceof URI) {
            return true;
        }
        if (!thing) {
            return false;
        }
        return (
            typeof (<URI>thing).authority === 'string' &&
            typeof (<URI>thing).fragment === 'string' &&
            typeof (<URI>thing).path === 'string' &&
            typeof (<URI>thing).query === 'string' &&
            typeof (<URI>thing).scheme === 'string' &&
            typeof (<URI>thing).fsPath === 'function' &&
            typeof (<URI>thing).with === 'function' &&
            typeof (<URI>thing).toString === 'function'
        );
    }

    /**
     * scheme is the 'http' part of 'http://www.msft.com/some/path?query#fragment'.
     * The part before the first colon.
     */
    readonly scheme: string;

    /**
     * authority is the 'www.msft.com' part of 'http://www.msft.com/some/path?query#fragment'.
     * The part between the first double slashes and the next slash.
     */
    readonly authority: string;

    /**
     * path is the '/some/path' part of 'http://www.msft.com/some/path?query#fragment'.
     */
    readonly path: string;

    /**
     * query is the 'query' part of 'http://www.msft.com/some/path?query#fragment'.
     */
    readonly query: string;

    /**
     * fragment is the 'fragment' part of 'http://www.msft.com/some/path?query#fragment'.
     */
    readonly fragment: string;

    /**
     * @internal
     */
    protected constructor(
        scheme: string,
        authority?: string,
        path?: string,
        query?: string,
        fragment?: string,
        _strict?: boolean,
    );

    /**
     * @internal
     */
    protected constructor(components: UriComponents);

    /**
     * @internal
     */
    protected constructor(
        schemeOrData: string | UriComponents,
        authority?: string,
        path?: string,
        query?: string,
        fragment?: string,
        _strict = false,
    ) {
        if (typeof schemeOrData === 'object') {
            this.scheme = schemeOrData.scheme || _empty;
            this.authority = schemeOrData.authority || _empty;
            this.path = schemeOrData.path || _empty;
            this.query = schemeOrData.query || _empty;
            this.fragment = schemeOrData.fragment || _empty;
            // no validation because it's this URI
            // that creates uri components.
            // _validateUri(this);
        } else {
            this.scheme = _schemeFix(schemeOrData, _strict);
            this.authority = authority || _empty;
            this.path = _referenceResolution(this.scheme, path || _empty);
            this.query = query || _empty;
            this.fragment = fragment || _empty;

            _validateUri(this, _strict);
        }
    }

    // ---- filesystem path -----------------------

    /**
         * Returns a string representing the corresponding file system path of this URI.
         * Will handle UNC paths, normalizes windows drive letters to lower-case, and uses the
         * platform specific path separator.
         *
         * * Will *not* validate the path for invalid characters and semantics.
         * * Will *not* look at the scheme of this URI.
         * * The result shall *not* be used for display purposes but for accessing a file on disk.
         *
         *
         * The *difference* to `URI#path` is the use of the platform specific separator and the handling
         * of UNC paths. See the below sample of a file-uri with an authority (UNC path).
         *
         * ```ts
            const u = URI.parse('file://server/c$/folder/file.txt')
            u.authority === 'server'
            u.path === '/shares/c$/file.txt'
            u.fsPath === '\\server\c$\folder\file.txt'
        ```
        *
        * Using `URI#path` to read a file (using fs-apis) would not be enough because parts of the path,
        * namely the server name, would be missing. Therefore `URI#fsPath` exists - it's sugar to ease working
        * with URIs that represent files on disk (`file` scheme).
        */
    get fsPath(): string {
        // if (this.scheme !== 'file') {
        // 	console.warn(`[UriError] calling fsPath with scheme ${this.scheme}`);
        // }
        return _makeFsPath(this);
    }

    // ---- modify to new -------------------------

    with(change: {
        scheme?: string;
        authority?: string | null;
        path?: string | null;
        query?: string | null;
        fragment?: string | null;
    }): URI {
        if (!change) {
            return this;
        }

        let { scheme, authority, path, query, fragment } = change;
        if (scheme === undefined) {
            scheme = this.scheme;
        } else if (scheme === null) {
            scheme = _empty;
        }
        if (authority === undefined) {
            authority = this.authority;
        } else if (authority === null) {
            authority = _empty;
        }
        if (path === undefined) {
            path = this.path;
        } else if (path === null) {
            path = _empty;
        }
        if (query === undefined) {
            query = this.query;
        } else if (query === null) {
            query = _empty;
        }
        if (fragment === undefined) {
            fragment = this.fragment;
        } else if (fragment === null) {
            fragment = _empty;
        }

        if (
            scheme === this.scheme &&
            authority === this.authority &&
            path === this.path &&
            query === this.query &&
            fragment === this.fragment
        ) {
            return this;
        }

        // eslint-disable-next-line @typescript-eslint/no-use-before-define
        return new _URI(scheme, authority, path, query, fragment);
    }

    // ---- parse & validate ------------------------

    /**
     * Creates a new URI from a string, e.g. `http://www.msft.com/some/path`,
     * `file:///usr/home`, or `scheme:with/path`.
     *
     * @param value A string which represents an URI (see `URI#toString`).
     * @param {boolean} [_strict=false]
     */
    static parse(value: string, _strict = false): URI {
        const match = _regexp.exec(value);
        if (!match) {
            // eslint-disable-next-line @typescript-eslint/no-use-before-define
            return new _URI(_empty, _empty, _empty, _empty, _empty);
        }
        // eslint-disable-next-line @typescript-eslint/no-use-before-define
        return new _URI(
            match[2] || _empty,
            decodeURIComponent(match[4] || _empty),
            decodeURIComponent(match[5] || _empty),
            decodeURIComponent(match[7] || _empty),
            decodeURIComponent(match[9] || _empty),
            _strict,
        );
    }

    /**
         * Creates a new URI from a file system path, e.g. `c:\my\files`,
         * `/usr/home`, or `\\server\share\some\path`.
         *
         * The *difference* between `URI#parse` and `URI#file` is that the latter treats the argument
         * as path, not as stringified-uri. E.g. `URI.file(path)` is **not the same as**
         * `URI.parse('file://' + path)` because the path might contain characters that are
         * interpreted (# and ?). See the following sample:
         * ```ts
        const good = URI.file('/coding/c#/project1');
        good.scheme === 'file';
        good.path === '/coding/c#/project1';
        good.fragment === '';
        const bad = URI.parse('file://' + '/coding/c#/project1');
        bad.scheme === 'file';
        bad.path === '/coding/c'; // path is now broken
        bad.fragment === '/project1';
        ```
        *
        * @param path A file system path (see `URI#fsPath`)
        */
    static file(path: string): URI {
        let authority = _empty;

        // normalize to fwd-slashes on windows,
        // on other systems bwd-slashes are valid
        // filename character, eg /f\oo/ba\r.txt
        if (isWindows) {
            path = path.replace(/\\/g, _slash);
        }

        // check for authority as used in UNC shares
        // or use the path as given
        if (path[0] === _slash && path[1] === _slash) {
            const idx = path.indexOf(_slash, 2);
            if (idx === -1) {
                authority = path.substring(2);
                path = _slash;
            } else {
                authority = path.substring(2, idx);
                path = path.substring(idx) || _slash;
            }
        }

        // eslint-disable-next-line @typescript-eslint/no-use-before-define
        return new _URI('file', authority, path, _empty, _empty);
    }

    static from(components: {
        scheme: string;
        authority?: string;
        path?: string;
        query?: string;
        fragment?: string;
    }): URI {
        // eslint-disable-next-line @typescript-eslint/no-use-before-define
        return new _URI(
            components.scheme,
            components.authority,
            components.path,
            components.query,
            components.fragment,
        );
    }

    // ---- printing/externalize ---------------------------

    /**
     * Creates a string representation for this URI. It's guaranteed that calling
     * `URI.parse` with the result of this function creates an URI which is equal
     * to this URI.
     *
     * * The result shall *not* be used for display purposes but for externalization or transport.
     * * The result will be encoded using the percentage encoding and encoding happens mostly
     * ignore the scheme-specific encoding rules.
     *
     * @param skipEncoding Do not encode the result, default is `false`
     */
    toString(skipEncoding = false): string {
        return _asFormatted(this, skipEncoding);
    }

    toJSON(): UriComponents {
        return this;
    }

    static revive(data: UriComponents | URI): URI;

    static revive(data: UriComponents | URI | undefined): URI | undefined;

    static revive(data: UriComponents | URI | null): URI | null;

    static revive(data: UriComponents | URI | undefined | null): URI | undefined | null;

    static revive(data: UriComponents | URI | undefined | null): URI | undefined | null {
        if (!data) {
            return data;
        }
        if (data instanceof URI) {
            return data;
        }
        // eslint-disable-next-line @typescript-eslint/no-use-before-define
        const result = new _URI(data);
        result._formatted = (<UriState>data).external;
        result._fsPath = (<UriState>data)._sep === _pathSepMarker ? (<UriState>data).fsPath : null;
        return result;
    }

    static joinPath(uri: URI, ...pathFragment: string[]): URI {
        if (!uri.path) {
            throw new Error(`[UriError]: cannot call joinPaths on URI without path`);
        }
        let newPath: string;
        if (isWindows && uri.scheme === 'file') {
            newPath = URI.file(pathImport.join(uri.fsPath, ...pathFragment)).path;
        } else {
            newPath = pathImport.join(uri.path, ...pathFragment);
        }
        return uri.with({ path: newPath });
    }
}

export interface UriComponents {
    scheme: string;
    authority: string;
    path: string;
    query: string;
    fragment: string;
}

interface UriState extends UriComponents {
    $mid: number;
    external: string;
    fsPath: string;
    _sep: 1 | undefined;
}

class _URI extends URI {
    _formatted: string | null = null;

    _fsPath: string | null = null;

    constructor(
        schemeOrData: string | UriComponents,
        authority?: string,
        path?: string,
        query?: string,
        fragment?: string,
        _strict = false,
    ) {
        super(schemeOrData as string, authority, path, query, fragment, _strict);
        this._fsPath = this.fsPath;
    }

    get fsPath(): string {
        if (!this._fsPath) {
            this._fsPath = _makeFsPath(this);
        }
        return this._fsPath;
    }

    toString(skipEncoding = false): string {
        if (!skipEncoding) {
            if (!this._formatted) {
                this._formatted = _asFormatted(this, false);
            }
            return this._formatted;
        }
        // we don't cache that
        return _asFormatted(this, true);
    }

    toJSON(): UriComponents {
        const res = <UriState>{
            $mid: 1,
        };
        // cached state
        if (this._fsPath) {
            res.fsPath = this._fsPath;
            if (_pathSepMarker) {
                res._sep = _pathSepMarker;
            }
        }
        if (this._formatted) {
            res.external = this._formatted;
        }
        // uri components
        if (this.path) {
            res.path = this.path;
        }
        if (this.scheme) {
            res.scheme = this.scheme;
        }
        if (this.authority) {
            res.authority = this.authority;
        }
        if (this.query) {
            res.query = this.query;
        }
        if (this.fragment) {
            res.fragment = this.fragment;
        }
        return res;
    }
}

// reserved characters: https://tools.ietf.org/html/rfc3986#section-2.2
const encodeTable: { [ch: number]: string } = {
    [CharCode.Colon]: '%3A', // gen-delims
    [CharCode.Slash]: '%2F',
    [CharCode.QuestionMark]: '%3F',
    [CharCode.Hash]: '%23',
    [CharCode.OpenSquareBracket]: '%5B',
    [CharCode.CloseSquareBracket]: '%5D',
    [CharCode.AtSign]: '%40',

    [CharCode.ExclamationMark]: '%21', // sub-delims
    [CharCode.DollarSign]: '%24',
    [CharCode.Ampersand]: '%26',
    [CharCode.SingleQuote]: '%27',
    [CharCode.OpenParen]: '%28',
    [CharCode.CloseParen]: '%29',
    [CharCode.Asterisk]: '%2A',
    [CharCode.Plus]: '%2B',
    [CharCode.Comma]: '%2C',
    [CharCode.Semicolon]: '%3B',
    [CharCode.Equals]: '%3D',

    [CharCode.Space]: '%20',
};

function encodeURIComponentFast(uriComponent: string, allowSlash: boolean): string {
    let res: string | undefined;
    let nativeEncodePos = -1;

    for (let pos = 0; pos < uriComponent.length; pos += 1) {
        const code = uriComponent.charCodeAt(pos);

        // unreserved characters: https://tools.ietf.org/html/rfc3986#section-2.3
        if (
            (code >= CharCode.a && code <= CharCode.z) ||
            (code >= CharCode.A && code <= CharCode.Z) ||
            (code >= CharCode.Digit0 && code <= CharCode.Digit9) ||
            code === CharCode.Dash ||
            code === CharCode.Period ||
            code === CharCode.Underline ||
            code === CharCode.Tilde ||
            (allowSlash && code === CharCode.Slash)
        ) {
            // check if we are delaying native encode
            if (nativeEncodePos !== -1) {
                res += encodeURIComponent(uriComponent.substring(nativeEncodePos, pos));
                nativeEncodePos = -1;
            }
            // check if we write into a new string (by default we try to return the param)
            if (res !== undefined) {
                res += uriComponent.charAt(pos);
            }
        } else {
            // encoding needed, we need to allocate a new string
            if (res === undefined) {
                res = uriComponent.substr(0, pos);
            }

            // check with default table first
            const escaped = encodeTable[code];
            if (escaped !== undefined) {
                // check if we are delaying native encode
                if (nativeEncodePos !== -1) {
                    res += encodeURIComponent(uriComponent.substring(nativeEncodePos, pos));
                    nativeEncodePos = -1;
                }

                // append escaped variant to result
                res += escaped;
            } else if (nativeEncodePos === -1) {
                // use native encode only when needed
                nativeEncodePos = pos;
            }
        }
    }

    if (nativeEncodePos !== -1) {
        res += encodeURIComponent(uriComponent.substring(nativeEncodePos));
    }

    return res !== undefined ? res : uriComponent;
}

function encodeURIComponentMinimal(path: string): string {
    let res: string | undefined;
    for (let pos = 0; pos < path.length; pos += 1) {
        const code = path.charCodeAt(pos);
        if (code === CharCode.Hash || code === CharCode.QuestionMark) {
            if (res === undefined) {
                res = path.substr(0, pos);
            }
            res += encodeTable[code];
        } else if (res !== undefined) {
            res += path[pos];
        }
    }
    return res !== undefined ? res : path;
}

/**
 * Compute `fsPath` for the given uri
 */
function _makeFsPath(uri: URI): string {
    let value: string;
    if (uri.authority && uri.path.length > 1 && uri.scheme === 'file') {
        // unc path: file://shares/c$/far/boo
        value = `//${uri.authority}${uri.path}`;
    } else if (
        uri.path.charCodeAt(0) === CharCode.Slash &&
        ((uri.path.charCodeAt(1) >= CharCode.A && uri.path.charCodeAt(1) <= CharCode.Z) ||
            (uri.path.charCodeAt(1) >= CharCode.a && uri.path.charCodeAt(1) <= CharCode.z)) &&
        uri.path.charCodeAt(2) === CharCode.Colon
    ) {
        // windows drive letter: file:///c:/far/boo
        value = uri.path[1].toLowerCase() + uri.path.substr(2);
    } else {
        // other path
        value = uri.path;
    }
    if (isWindows) {
        value = value.replace(/\//g, '\\');
    }
    return value;
}

/**
 * Create the external version of a uri
 */
function _asFormatted(uri: URI, skipEncoding: boolean): string {
    const encoder = !skipEncoding ? encodeURIComponentFast : encodeURIComponentMinimal;

    let res = '';
    let { authority, path } = uri;
    const { scheme, query, fragment } = uri;
    if (scheme) {
        res += scheme;
        res += ':';
    }
    if (authority || scheme === 'file') {
        res += _slash;
        res += _slash;
    }
    if (authority) {
        let idx = authority.indexOf('@');
        if (idx !== -1) {
            // <user>@<auth>
            const userinfo = authority.substr(0, idx);
            authority = authority.substr(idx + 1);
            idx = userinfo.indexOf(':');
            if (idx === -1) {
                res += encoder(userinfo, false);
            } else {
                // <user>:<pass>@<auth>
                res += encoder(userinfo.substr(0, idx), false);
                res += ':';
                res += encoder(userinfo.substr(idx + 1), false);
            }
            res += '@';
        }
        authority = authority.toLowerCase();
        idx = authority.indexOf(':');
        if (idx === -1) {
            res += encoder(authority, false);
        } else {
            // <auth>:<port>
            res += encoder(authority.substr(0, idx), false);
            res += authority.substr(idx);
        }
    }
    if (path) {
        // lower-case windows drive letters in /C:/fff or C:/fff
        if (path.length >= 3 && path.charCodeAt(0) === CharCode.Slash && path.charCodeAt(2) === CharCode.Colon) {
            const code = path.charCodeAt(1);
            if (code >= CharCode.A && code <= CharCode.Z) {
                path = `/${String.fromCharCode(code + 32)}:${path.substr(3)}`; // "/c:".length === 3
            }
        } else if (path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) {
            const code = path.charCodeAt(0);
            if (code >= CharCode.A && code <= CharCode.Z) {
                path = `${String.fromCharCode(code + 32)}:${path.substr(2)}`; // "/c:".length === 3
            }
        }
        // encode the rest of the path
        res += encoder(path, true);
    }
    if (query) {
        res += '?';
        res += encoder(query, false);
    }
    if (fragment) {
        res += '#';
        res += !skipEncoding ? encodeURIComponentFast(fragment, false) : fragment;
    }
    return res;
}
