/*
 * Copyright (C) 2018 Apple Inc. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
 * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
 * THE POSSIBILITY OF SUCH DAMAGE.
 */

WI.AuditTestCase = class AuditTestCase extends WI.AuditTestBase
{
    constructor(name, test, options = {})
    {
        console.assert(typeof test === "string", test);

        super(name, options);

        this._test = test;
    }

    // Static

    static async fromPayload(payload)
    {
        if (typeof payload !== "object" || payload === null)
            return null;

        if (payload.type !== WI.AuditTestCase.TypeIdentifier)
            return null;

        if (typeof payload.name !== "string") {
            WI.AuditManager.synthesizeError(WI.UIString("\u0022%s\u0022 has a non-string \u0022%s\u0022 value").format(payload.name, WI.unlocalizedString("name")));
            return null;
        }

        if (typeof payload.test !== "string") {
            WI.AuditManager.synthesizeError(WI.UIString("\u0022%s\u0022 has a non-string \u0022%s\u0022 value").format(payload.name, WI.unlocalizedString("test")));
            return null;
        }

        let options = {};

        if (typeof payload.description === "string")
            options.description = payload.description;
        else if ("description" in payload)
            WI.AuditManager.synthesizeWarning(WI.UIString("\u0022%s\u0022 has a non-string \u0022%s\u0022 value").format(payload.name, WI.unlocalizedString("description")));

        if (typeof payload.supports === "number")
            options.supports = payload.supports;
        else if ("supports" in payload)
            WI.AuditManager.synthesizeWarning(WI.UIString("\u0022%s\u0022 has a non-number \u0022%s\u0022 value").format(payload.name, WI.unlocalizedString("supports")));

        if (typeof payload.setup === "string")
            options.setup = payload.setup;
        else if ("setup" in payload)
            WI.AuditManager.synthesizeWarning(WI.UIString("\u0022%s\u0022 has a non-string \u0022%s\u0022 value").format(payload.name, WI.unlocalizedString("setup")));

        if (typeof payload.disabled === "boolean")
            options.disabled = payload.disabled;

        return new WI.AuditTestCase(payload.name, payload.test, options);
    }

    static stringifyFunction(func, indentLevel)
    {
        let string = func.toString();

        // Remove spaces to make the function look unindented.
        string = string.replaceAll(new RegExp(`^ {${indentLevel}}`, "gm"), "");

        // Replace remaining indentations with the user set indent string.
        string = string.replaceAll(/^    /gm, WI.indentString());

        return string;
    }

    // Public

    get test()
    {
        return this._test;
    }

    set test(test)
    {
        console.assert(this.editable);
        console.assert(WI.auditManager.editing);
        console.assert(typeof test === "string", test);

        if (test === this._test)
            return;

        this._test = test;

        this.clearResult();

        this.dispatchEventToListeners(WI.AuditTestBase.Event.TestChanged);
    }

    toJSON(key)
    {
        let json = super.toJSON(key);
        json.test = this._test;
        return json;
    }

    // Protected

    async run()
    {
        const levelStrings = Object.values(WI.AuditTestCaseResult.Level);
        let level = null;
        let data = {};
        let metadata = {
            url: WI.networkManager.mainFrame.url,
            startTimestamp: null,
            endTimestamp: null,
        };
        let resolvedDOMNodes = null;

        function setLevel(newLevel) {
            let newLevelIndex = levelStrings.indexOf(newLevel);
            if (newLevelIndex < 0) {
                addError(WI.UIString("Return string must be one of %s").format(JSON.stringify(levelStrings)));
                return;
            }

            if (newLevelIndex <= levelStrings.indexOf(level))
                return;

            level = newLevel;
        }

        function addError(value) {
            setLevel(WI.AuditTestCaseResult.Level.Error);

            if (!data.errors)
                data.errors = [];

            data.errors.push(value);
        }

        async function parseResponse(response) {
            let remoteObject = WI.RemoteObject.fromPayload(response.result, WI.mainTarget);
            if (response.wasThrown || (remoteObject.type === "object" && remoteObject.subtype === "error")) {
                addError(remoteObject.description);
                return;
            }

            if (remoteObject.type === "boolean") {
                setLevel(remoteObject.value ? WI.AuditTestCaseResult.Level.Pass : WI.AuditTestCaseResult.Level.Fail);
                return;
            }

            if (remoteObject.type === "string") {
                setLevel(remoteObject.value.trim().toLowerCase());
                return;
            }

            if (remoteObject.type !== "object" || remoteObject.subtype) {
                addError(WI.UIString("Return value is not an object, string, or boolean"));
                return;
            }

            const options = {
                ownProperties: true,
            };

            function checkResultProperty(key, value, type, subtype) {
                function addErrorForValueType(valueType) {
                    let errorString = null;
                    if (valueType === "object" || valueType === "array")
                        errorString = WI.UIString("\u0022%s\u0022 must be an %s");
                    else
                        errorString = WI.UIString("\u0022%s\u0022 must be a %s");
                    addError(errorString.format(key, valueType));
                }

                if (value.subtype !== subtype) {
                    addErrorForValueType(subtype);
                    return null;
                }

                if (value.type !== type) {
                    addErrorForValueType(type);
                    return null;
                }

                if (type === "boolean" || type === "string")
                    return value.value;

                return value;
            }

            async function resultArrayForEach(key, value, callback) {
                let array = checkResultProperty(key, value, "object", "array");
                if (!array)
                    return;

                let arrayProperties = await new Promise((resolve, reject) => array.getPropertyDescriptors(resolve, options));
                for (let i = 0; i < array.size; ++i) {
                    let arrayPropertyForIndex = arrayProperties.find((arrayProperty) => arrayProperty.name === String(i));
                    if (arrayPropertyForIndex)
                        await callback(arrayPropertyForIndex);
                }
            }

            let properties = await new Promise((resolve, reject) => remoteObject.getPropertyDescriptors(resolve, options));
            for (let property of properties) {
                let key = property.name;
                if (key === "__proto__")
                    continue;

                let value = property.value;

                switch (key) {
                case "level": {
                    let levelString = checkResultProperty(key, value, "string");
                    if (levelString)
                        setLevel(levelString.trim().toLowerCase());
                    break;
                }

                case "pass":
                    if (checkResultProperty(key, value, "boolean"))
                        setLevel(WI.AuditTestCaseResult.Level.Pass);
                    break;

                case "warn":
                    if (checkResultProperty(key, value, "boolean"))
                        setLevel(WI.AuditTestCaseResult.Level.Warn);
                    break;

                case "fail":
                    if (checkResultProperty(key, value, "boolean"))
                        setLevel(WI.AuditTestCaseResult.Level.Fail);
                    break;

                case "error":
                    if (checkResultProperty(key, value, "boolean"))
                        setLevel(WI.AuditTestCaseResult.Level.Error);
                    break;

                case "unsupported":
                    if (checkResultProperty(key, value, "boolean"))
                        setLevel(WI.AuditTestCaseResult.Level.Unsupported);
                    break;

                case "domNodes":
                    await resultArrayForEach(key, value, async (item) => {
                        if (!item || !item.value || item.value.type !== "object" || item.value.subtype !== "node") {
                            addError(WI.UIString("All items in \u0022%s\u0022 must be valid DOM nodes").format(WI.unlocalizedString("domNodes")));
                            return;
                        }

                        let domNodeId = await new Promise((resolve, reject) => item.value.pushNodeToFrontend(resolve));
                        let domNode = WI.domManager.nodeForId(domNodeId);
                        if (!domNode)
                            return;

                        if (!data.domNodes)
                            data.domNodes = [];
                        data.domNodes.push(WI.cssPath(domNode, {full: true}));

                        if (!resolvedDOMNodes)
                            resolvedDOMNodes = [];
                        resolvedDOMNodes.push(domNode);
                    });
                    break;

                case "domAttributes":
                    await resultArrayForEach(key, value, (item) => {
                        if (!item || !item.value || item.value.type !== "string" || !item.value.value.length) {
                            addError(WI.UIString("All items in \u0022%s\u0022 must be non-empty strings").format(WI.unlocalizedString("domAttributes")));
                            return;
                        }

                        if (!data.domAttributes)
                            data.domAttributes = [];
                        data.domAttributes.push(item.value.value);
                    });
                    break;

                case "errors":
                    await resultArrayForEach(key, value, (item) => {
                        if (!item || !item.value || item.value.type !== "object" || item.value.subtype !== "error") {
                            addError(WI.UIString("All items in \u0022%s\u0022 must be error objects").format(WI.unlocalizedString("errors")));
                            return;
                        }

                        addError(item.value.description);
                    });
                    break;

                default:
                    if (value.objectId) {
                        try {
                            function inspectedPage_stringify() {
                                return JSON.stringify(this);
                            }
                            let stringifiedValue = await value.callFunction(inspectedPage_stringify);
                            data[key] = JSON.parse(stringifiedValue.value);
                        } catch {
                            addError(WI.UIString("\u0022%s\u0022 is not JSON serializable").format(key));
                        }
                    } else
                        data[key] = value.value;
                    break;
                }
            }
        }

        let target = WI.assumingMainTarget();

        let agentCommandFunction = null;
        let agentCommandArguments = {};
        if (target.hasDomain("Audit")) {
            agentCommandFunction = target.AuditAgent.run;
            agentCommandArguments.test = this._test;
        } else {
            agentCommandFunction = target.RuntimeAgent.evaluate;
            agentCommandArguments.expression = `(function() { "use strict"; return eval(\`(${this._test.replace(/`/g, "\\`")})\`)(); })()`;
            agentCommandArguments.objectGroup = WI.AuditTestCase.ObjectGroup;
            agentCommandArguments.doNotPauseOnExceptionsAndMuteConsole = true;
        }

        try {
            metadata.startTimestamp = new Date;
            let response = await agentCommandFunction.invoke(agentCommandArguments);
            metadata.endTimestamp = new Date;

            if (response.result.type === "object" && response.result.className === "Promise") {
                if (WI.RuntimeManager.supportsAwaitPromise()) {
                    metadata.asyncTimestamp = metadata.endTimestamp;
                    response = await target.RuntimeAgent.awaitPromise(response.result.objectId);
                    metadata.endTimestamp = new Date;
                } else {
                    response = null;
                    addError(WI.UIString("Async audits are not supported."));
                    setLevel(WI.AuditTestCaseResult.Level.Unsupported);
                }
            }

            if (response)
                await parseResponse(response);
        } catch (error) {
            metadata.endTimestamp = new Date;
            addError(error.message);
        }

        if (!level)
            addError(WI.UIString("Missing result level"));

        let options = {
            description: this.description,
            metadata,
        };
        if (!isEmptyObject(data))
            options.data = data;
        if (resolvedDOMNodes)
            options.resolvedDOMNodes = resolvedDOMNodes;
        this.updateResult(new WI.AuditTestCaseResult(this.name, level, options));
    }
};

WI.AuditTestCase.TypeIdentifier = "test-case";
