import globalize from './globalize';

type ArbitraryFunction = () => unknown;

type SelectorMap = {
    selector: string;
    options?: DeprecationOptions;
};

/**
 * Purely to reflect the existing state of the code, not ideal.
 */
export type DeprecationOptions = {
    /**
     * the version this has been deprecated since
     */
    sinceVersion?: string;
    /**
     * the version this will be removed in
     */
    removeInVersion?: string;
    /**
     * the name of an alternative to use
     */
    alternativeName?: string;
    /**
     * extra information to be printed at the end of the deprecation log
     */
    extraInfo?: string;
    /**
     * an extra object that will be printed at the end
     */
    extraObject?: object | string;
    /**
     * a human-readable name to show in the deprecation message. If not provided, it is inferred from the function or object being deprecated.
     */
    displayName?: string;
    /**
     * type of the deprecation to append to the start of the deprecation message. e.g. JS or CSS
     */
    deprecationType?: string;
};

// eslint-disable-next-line @typescript-eslint/unbound-method -- Leaving behaviour identical for now
const has = Object.prototype.hasOwnProperty;
const deprecationCalls: (string | string[])[] = [];

function toSentenceCase(name: string): string {
    if (!name) {
        return '';
    }
    name = '' + name; // eslint-disable-line @typescript-eslint/no-unnecessary-type-conversion

    return name.charAt(0).toUpperCase() + name.substring(1);
}

function getDeprecatedLocation(printFrameOffset: number) {
    const error = new Error();

    let stacktraceString: string | undefined =
        error.stack ??
        // @ts-expect-error -- preserving the legacy, not sure what .stacktrace would exist on, guessing the type
        (error.stacktrace as string | undefined);
    stacktraceString = stacktraceString?.replace(/^Error\n/, '') ?? '';

    const stacktrace = stacktraceString.split('\n');
    return stacktrace[printFrameOffset + 2];
}

function logger(...args: unknown[]): void {
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JS test DOM environments
    if (typeof console !== 'undefined' && console.warn) {
        Function.prototype.apply.call(console.warn, console, args);
    }
}

/**
 * Return a function that logs a deprecation warning to the console the first time it is called from a certain location.
 * It will also print the stack frame of the calling function.
 *
 * @param {string} displayName the name of the thing being deprecated
 * @param {DeprecationOptions} options
 * @return {Function} that logs the warning and stack frame of the calling function. Takes in an optional parameter for the offset of
 * the stack frame to print, the default is 0. For example, 0 will log it for the line of the calling function,
 * -1 will print the location the logger was called from
 */
const getShowDeprecationMessagePublic = (
    displayName: string,
    options: DeprecationOptions
): ArbitraryFunction => {
    return getShowDeprecationMessageInternal(displayName, options);
};

/**
 * Return a function that logs a deprecation warning to the console the first time it is called from a certain location.
 * It will also print the stack frame of the calling function.
 *
 * @param {string | symbol | number | Function} displayName the name of the thing being deprecated
 * @param {DeprecationOptions} [options]
 * @return {Function} that logs the warning and stack frame of the calling function. Takes in an optional parameter for the offset of
 * the stack frame to print, the default is 0. For example, 0 will log it for the line of the calling function,
 * -1 will print the location the logger was called from
 */
function getShowDeprecationMessageInternal(
    displayName: string | ArbitraryFunction,
    options?: DeprecationOptions
): ArbitraryFunction {
    // This can be used internally to pas in a showmessage fn
    if (typeof displayName === 'function') {
        return displayName;
    }

    let called = false;
    options = options ?? {};

    return function (printFrameOffset?: number): void {
        let deprecatedLocation = getDeprecatedLocation(printFrameOffset ?? 1) ?? '';
        // Only log once if the stack frame doesn't exist to avoid spamming the console/test output
        if (!called || !deprecationCalls.includes(deprecatedLocation)) {
            deprecationCalls.push(deprecatedLocation);

            called = true;

            const deprecationType = options.deprecationType ?? '';

            let message =
                'DEPRECATED ' +
                deprecationType +
                '- ' +
                toSentenceCase(displayName) +
                ' has been deprecated' +
                (options.sinceVersion ? ' since ' + options.sinceVersion : '') +
                ' and will be removed in ' +
                (options.removeInVersion ?? 'a future release') +
                '.';

            if (options.alternativeName) {
                message += ' Use ' + options.alternativeName + ' instead. ';
            }

            if (options.extraInfo) {
                message += ' ' + options.extraInfo;
            }

            if (deprecatedLocation === '') {
                deprecatedLocation =
                    ' \n ' +
                    'No stack trace of the deprecated usage is available in your current browser.';
            } else {
                deprecatedLocation = ' \n ' + deprecatedLocation;
            }

            if (options.extraObject) {
                message += '\n';
                logger(message, options.extraObject, deprecatedLocation);
            } else {
                logger(message, deprecatedLocation);
            }
        }
    };
}

function logCssDeprecation(selectorMap: SelectorMap, newNode: Node) {
    let displayName = selectorMap.options?.displayName;
    displayName = displayName ? ' (' + displayName + ')' : '';

    const options: DeprecationOptions = Object.assign(
        {
            deprecationType: 'CSS',
            extraObject: newNode,
        },
        selectorMap.options
    );

    getShowDeprecationMessageInternal(
        "'" + selectorMap.selector + "' pattern" + displayName,
        options
    )();
}

/**
 * Returns a wrapped version of the function that logs a deprecation warning when the function is used.
 * @param {Function} fn the fn to wrap
 * @param {string} displayName the name of the fn to be displayed in the message
 * @param {DeprecationOptions} options
 * @return {Function} wrapping the original function
 */
function deprecateFunctionExpression(
    fn: ArbitraryFunction,
    displayName: string,
    options: DeprecationOptions
): ArbitraryFunction {
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Just in case someone outside AUI is using it
    options = options ?? {};
    options.deprecationType = options.deprecationType ?? 'JS';

    const showDeprecationMessage = getShowDeprecationMessageInternal(
        displayName || fn.name || 'this function',
        options
    );
    return function (...args: unknown[]) {
        showDeprecationMessage();
        // @ts-expect-error Sorry TS, don't want to change this behaviour just yet in case of side effects
        return fn.apply(this, args);
    };
}

/**
 * Returns a wrapped version of the constructor that logs a deprecation warning when the constructor is instantiated.
 * @param {Function} constructorFn the constructor function to wrap
 * @param {string} displayName the name of the fn to be displayed in the message
 * @param {DeprecationOptions} options
 * @return {Function} wrapping the original function
 */
function deprecateConstructor(
    constructorFn: ArbitraryFunction,
    displayName: string,
    options: DeprecationOptions
): ArbitraryFunction {
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Just in case someone outside AUI is using it
    options = options ?? {};
    options.deprecationType = options.deprecationType ?? 'JS';

    const deprecatedConstructor = deprecateFunctionExpression(constructorFn, displayName, options);
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    deprecatedConstructor.prototype = constructorFn.prototype;
    Object.assign(deprecatedConstructor, constructorFn); //copy static methods across;

    return deprecatedConstructor;
}

/**
 * Wraps a "value" object property in a deprecation warning in browsers supporting Object.defineProperty
 * @param {Object} obj the object containing the property
 * @param {string} prop the name of the property to deprecate
 * @param {DeprecationOptions} [options]
 */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- This is easier to read
function deprecateValueProperty<T extends object, K extends keyof T & string>(
    obj: T,
    prop: K,
    options?: DeprecationOptions
) {
    let oldVal = obj[prop];
    options = options ?? {};
    options.deprecationType = options.deprecationType ?? 'JS';

    const displayNameOrShowMessageFn = options.displayName ?? prop;
    const showDeprecationMessage = getShowDeprecationMessageInternal(
        displayNameOrShowMessageFn,
        options
    );
    Object.defineProperty(obj, prop, {
        get: function (): T[K] {
            showDeprecationMessage();
            return oldVal;
        },
        set: function (val: T[K]): T[K] {
            oldVal = val;
            showDeprecationMessage();
            return val;
        },
    });
}

/**
 * Wraps an object property in a deprecation warning, if possible. functions will always log warnings, but other
 * types of properties will only log in browsers supporting Object.defineProperty
 * @param {Object} object the object containing the property
 * @param {string} propertyKey the name of the property to deprecate
 * @param {DeprecationOptions} options
 */
function deprecateObjectProperty<T extends object>(
    object: T,
    propertyKey: string & keyof T,
    options: DeprecationOptions
) {
    if (typeof object[propertyKey] === 'function') {
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Just in case someone outside AUI is using it
        options = options ?? {};
        options.deprecationType = options.deprecationType ?? 'JS';

        const displayNameOrShowMessageFn = options.displayName ?? propertyKey;
        // @ts-expect-error -- Maybe a TypeScript wizard can figure out something
        // better than me so TS is happy. We only care it's a function.
        object[propertyKey] = deprecateFunctionExpression(
            object[propertyKey] as ArbitraryFunction,
            displayNameOrShowMessageFn,
            options
        );
    } else {
        deprecateValueProperty(object, propertyKey, options);
    }
}

type DeprecationOptionsWithAltNamePrefix = DeprecationOptions & {
    /**
     * a prefix for the alternative property name. Used to generate alternativeName per property.
     */
    alternativeNamePrefix?: string;
};

/**
 * Wraps all an objects properties in a deprecation warning, if possible. functions will always log warnings, but other
 * types of properties will only log in browsers supporting Object.defineProperty
 * @param {Object} obj the object to be wrapped
 * @param {string} objDisplayPrefix the object's prefix to be used in logs
 * @param {DeprecationOptionsWithAltNamePrefix} options
 */
function deprecateAllProperties(
    obj: object,
    objDisplayPrefix: string,
    options: DeprecationOptionsWithAltNamePrefix
) {
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Just in case someone outside AUI is using it
    options = options ?? {};
    for (const attr in obj) {
        if (has.call(obj, attr)) {
            options.deprecationType = options.deprecationType ?? 'JS';
            options.displayName = objDisplayPrefix + attr;
            options.alternativeName =
                options.alternativeNamePrefix && options.alternativeNamePrefix + attr;
            deprecateObjectProperty(
                obj,
                // @ts-expect-error -- We're very safely checking this is actually an attribute on the object
                attr,
                Object.assign({}, options)
            );
        }
    }
}

function matchesSelector(node: Node, selector: string) {
    if (node instanceof Element) {
        return node.matches(selector);
    }
    return false;
}

function handleAddingSelector(options?: DeprecationOptions) {
    return function (selector: string) {
        const selectorMap: SelectorMap = {
            selector: selector,
            options: options,
        };

        // Search if matches have already been added
        const matches = document.querySelectorAll(selector);
        for (const match of matches) {
            logCssDeprecation(selectorMap, match);
        }

        observeFutureChange(selectorMap);
    };
}

/**
 * Return a function that logs a deprecation warning to the console the first time it is called from a certain location.
 * It will also print the stack frame of the calling function.
 */
function deprecateCSS(selectors: string | string[], options?: DeprecationOptions): void {
    if (typeof selectors === 'string') {
        selectors = [selectors];
    }

    selectors.forEach(handleAddingSelector(options));
}

function testAndHandleDeprecation(newNode: Node) {
    return function (selectorMap: SelectorMap) {
        if (matchesSelector(newNode, selectorMap.selector)) {
            logCssDeprecation(selectorMap, newNode);
        }
    };
}

const deprecatedSelectorMap: SelectorMap[] = [];
let observer: MutationObserver | undefined = undefined;

function observeFutureChange(selectorMap: SelectorMap) {
    deprecatedSelectorMap.push(selectorMap);

    // Lazily instantiate a mutation observer because they're expensive.
    if (observer === undefined) {
        observer = new MutationObserver(function (mutations) {
            mutations.forEach(function (mutation) {
                // TODO - should this also look at class changes, if possible?
                const addedNodes = mutation.addedNodes;

                for (const newNode of addedNodes) {
                    if (newNode.nodeType === 1) {
                        deprecatedSelectorMap.forEach(testAndHandleDeprecation(newNode));
                    }
                }
            });
        });

        const config = {
            childList: true,
            subtree: true,
        };

        observer.observe(document, config);
    }
}

globalize('deprecate', {
    fn: deprecateFunctionExpression,
    construct: deprecateConstructor,
    css: deprecateCSS,
    prop: deprecateObjectProperty,
    obj: deprecateAllProperties,
    getMessageLogger: getShowDeprecationMessagePublic,
});

export {
    deprecateFunctionExpression as fn,
    deprecateConstructor as construct,
    deprecateCSS as css,
    deprecateObjectProperty as prop,
    deprecateAllProperties as obj,
    getShowDeprecationMessagePublic as getMessageLogger,
};
