// Useful constants for working with COSE key objects
const cose_kty = 1;
const cose_kty_ec2 = 2;
const cose_alg = 3;
const cose_alg_ECDSA_w_SHA256 = -7;
const cose_alg_ECDSA_w_SHA512 = -36;
const cose_crv = -1;
const cose_crv_P256 = 1;
const cose_crv_x = -2;
const cose_crv_y = -3;

/**
 * These are the default arguments that will be passed to navigator.credentials.create()
 * unless modified by a specific test case
 */
var createCredentialDefaultArgs = {
    options: {
        publicKey: {
            // Relying Party:
            rp: {
                name: "Acme",
            },

            // User:
            user: {
                id: new Uint8Array(16), // Won't survive the copy, must be rebuilt
                name: "john.p.smith@example.com",
                displayName: "John P. Smith",
            },

            pubKeyCredParams: [{
                type: "public-key",
                alg: cose_alg_ECDSA_w_SHA256,
            }],

            authenticatorSelection: {
                requireResidentKey: false,
            },

            timeout: 60000, // 1 minute
            excludeCredentials: [] // No excludeList
        }
    }
};

/**
 * These are the default arguments that will be passed to navigator.credentials.get()
 * unless modified by a specific test case
 */
var getCredentialDefaultArgs = {
    options: {
        publicKey: {
            timeout: 60000
            // allowCredentials: [newCredential]
        }
    }
};

function createCredential(opts) {
    opts = opts || {};

    // set the default options
    var createArgs = cloneObject(createCredentialDefaultArgs);
    let challengeBytes = new Uint8Array(16);
    window.crypto.getRandomValues(challengeBytes);
    createArgs.options.publicKey.challenge = challengeBytes;
    createArgs.options.publicKey.user.id = new Uint8Array(16);

    // change the defaults with any options that were passed in
    extendObject(createArgs, opts);

    // create the credential, return the Promise
    return navigator.credentials.create(createArgs.options);
}

function assertCredential(credential) {
    var options = cloneObject(getCredentialDefaultArgs);
    let challengeBytes = new Uint8Array(16);
    window.crypto.getRandomValues(challengeBytes);
    options.challenge = challengeBytes;
    options.allowCredentials = [{type: 'public-key', id: credential.rawId}];
    return navigator.credentials.get({publicKey: options});
}

function createRandomString(len) {
    var text = "";
    var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    for(var i = 0; i < len; i++) {
        text += possible.charAt(Math.floor(Math.random() * possible.length));
    }
    return text;
}


function ab2str(buf) {
    return String.fromCharCode.apply(null, new Uint8Array(buf));
}

// Useful constants for working with attestation data
const authenticator_data_user_present = 0x01;
const authenticator_data_user_verified = 0x04;
const authenticator_data_attested_cred_data = 0x40;
const authenticator_data_extension_data = 0x80;

function parseAuthenticatorData(buf) {
    if (buf.byteLength < 37) {
        throw new TypeError ("parseAuthenticatorData: buffer must be at least 37 bytes");
    }

    printHex ("authnrData", buf);

    var authnrData = new DataView(buf);
    var authnrDataObj = {};
    authnrDataObj.length = buf.byteLength;

    authnrDataObj.rpIdHash = new Uint8Array (buf.slice (0,32));
    authnrDataObj.rawFlags = authnrData.getUint8(32);
    authnrDataObj.counter = authnrData.getUint32(33, false);
    authnrDataObj.rawCounter = [];
    authnrDataObj.rawCounter[0] = authnrData.getUint8(33);
    authnrDataObj.rawCounter[1] = authnrData.getUint8(34);
    authnrDataObj.rawCounter[2] = authnrData.getUint8(35);
    authnrDataObj.rawCounter[3] = authnrData.getUint8(36);
    authnrDataObj.flags = {};

    authnrDataObj.flags.userPresent = (authnrDataObj.rawFlags&authenticator_data_user_present)?true:false;
    authnrDataObj.flags.userVerified = (authnrDataObj.rawFlags&authenticator_data_user_verified)?true:false;
    authnrDataObj.flags.attestedCredentialData = (authnrDataObj.rawFlags&authenticator_data_attested_cred_data)?true:false;
    authnrDataObj.flags.extensionData = (authnrDataObj.rawFlags&authenticator_data_extension_data)?true:false;

    return authnrDataObj;
}

/**
 * TestCase
 *
 * A generic template for test cases
 * Is intended to be overloaded with subclasses that override testObject, testFunction and argOrder
 * The testObject is the default arguments for the testFunction
 * The default testObject can be modified with the modify() method, making it easy to create new tests based on the default
 * The testFunction is the target of the test and is called by the doIt() method. doIt() applies the testObject as arguments via toArgs()
 * toArgs() uses argOrder to make sure the resulting array is in the right order of the arguments for the testFunction
 */
class TestCase {
    constructor() {
        this.testFunction = function() {
            throw new Error("Test Function not implemented");
        };
        this.testObject = {};
        this.argOrder = [];
        this.ctx = null;
    }

    /**
     * toObject
     *
     * return a copy of the testObject
     */
    toObject() {
        return JSON.parse(JSON.stringify(this.testObject)); // cheap clone
    }

    /**
     * toArgs
     *
     * converts test object to an array that is ordered in the same way as the arguments to the test function
     */
    toArgs() {
        var ret = [];
        // XXX, TODO: this won't necessarily produce the args in the right order
        for (let idx of this.argOrder) {
            ret.push(this.testObject[idx]);
        }
        return ret;
    }

    /**
     * modify
     *
     * update the internal object by a path / value combination
     * e.g. :
     * modify ("foo.bar", 3)
     * accepts three types of args:
     *    "foo.bar", 3
     *    {path: "foo.bar", value: 3}
     *    [{path: "foo.bar", value: 3}, ...]
     */
    modify(arg1, arg2) {
        var mods;

        // check for the two argument scenario
        if (typeof arg1 === "string" && arg2 !== undefined) {
            mods = {
                path: arg1,
                value: arg2
            };
        } else {
            mods = arg1;
        }

        // accept a single modification object instead of an array
        if (!Array.isArray(mods) && typeof mods === "object") {
            mods = [mods];
        }

        // iterate through each of the desired modifications, and call recursiveSetObject on them
        for (let idx in mods) {
            var mod = mods[idx];
            let paths = mod.path.split(".");
            recursiveSetObject(this.testObject, paths, mod.value);
        }

        // iterates through nested `obj` using the `pathArray`, creating the path if it doesn't exist
        // when the final leaf of the path is found, it is assigned the specified value
        function recursiveSetObject(obj, pathArray, value) {
            var currPath = pathArray.shift();
            if (typeof obj[currPath] !== "object") {
                obj[currPath] = {};
            }
            if (pathArray.length > 0) {
                return recursiveSetObject(obj[currPath], pathArray, value);
            }
            obj[currPath] = value;
        }

        return this;
    }

    /**
     * actually runs the test function with the supplied arguments
     */
    doIt() {
        if (typeof this.testFunction !== "function") {
            throw new Error("Test function not found");
        }

        return this.testFunction.call(this.ctx, ...this.toArgs());
    }

    /**
     * run the test function with the top-level properties of the test object applied as arguments
     * expects the test to pass, and then validates the results
     */
    testPasses(desc) {
        return this.doIt()
            .then((ret) => {
                // check the result
                this.validateRet(ret);
                return ret;
            });
    }

    /**
     * run the test function with the top-level properties of the test object applied as arguments
     * expects the test to fail
     */
    testFails(t, testDesc, expectedErr) {
        if (typeof expectedErr == "string") {
            return promise_rejects_dom(t, expectedErr, this.doIt(), "Expected  bad parameters to fail");
        }

        return promise_rejects_js(t, expectedErr, this.doIt(), "Expected bad parameters to fail");
    }

    /**
     * Runs the test that's implemented by the class by calling the doIt() function
     * @param  {String} desc                A description of the test being run
     * @param  [Error|String] expectedErr   A string matching an error type, such as "SecurityError" or an object with a .name value that is an error type string
     */
    runTest(desc, expectedErr) {
        promise_test((t) => {
            return Promise.resolve().then(() => {
                return this.testSetup();
            }).then(() => {
                if (expectedErr === undefined) {
                    return this.testPasses(desc);
                } else {
                    return this.testFails(t, desc, expectedErr);
                }
            }).then((res) => {
                return this.testTeardown(res);
            })
        }, desc)
    }

    /**
     * called before runTest
     * virtual method expected to be overridden by child class if needed
     */
    testSetup() {
        if (this.beforeTestFn) {
            this.beforeTestFn.call(this);
        }

        return Promise.resolve();
    }

    /**
     * Adds a callback function that gets called in the TestCase context
     * and within the testing process.
     */
    beforeTest(fn) {
        if (typeof fn !== "function") {
            throw new Error ("Tried to call non-function before test");
        }

        this.beforeTestFn = fn;

        return this;
    }

    /**
     * called after runTest
     * virtual method expected to be overridden by child class if needed
     */
    testTeardown(res) {
        if (this.afterTestFn) {
            this.afterTestFn.call(this, res);
        }

        return Promise.resolve();
    }

    /**
     * Adds a callback function that gets called in the TestCase context
     * and within the testing process. Good for validating results.
     */
    afterTest(fn) {
        if (typeof fn !== "function") {
            throw new Error ("Tried to call non-function after test");
        }

        this.afterTestFn = fn;

        return this;
    }

    /**
     * validates the value returned from the test function
     * virtual method expected to be overridden by child class
     */
    validateRet() {
        throw new Error("Not implemented");
    }
}

function cloneObject(o) {
    return JSON.parse(JSON.stringify(o));
}

function extendObject(dst, src) {
    Object.keys(src).forEach(function(key) {
        if (isSimpleObject(src[key]) && !isAbortSignal(src[key])) {
            dst[key] ||= {};
            extendObject(dst[key], src[key]);
        } else {
            dst[key] = src[key];
        }
    });
}

function isSimpleObject(o) {
    return (typeof o === "object" &&
        !Array.isArray(o) &&
        !(o instanceof ArrayBuffer));
}

function isAbortSignal(o) {
    return (o instanceof AbortSignal);
}

/**
 * CreateCredentialTest
 *
 * tests the WebAuthn navigator.credentials.create() interface
 */
class CreateCredentialsTest extends TestCase {
    constructor() {
        // initialize the parent class
        super();

        // the function to be tested
        this.testFunction = navigator.credentials.create;
        // the context to call the test function with (i.e. - the 'this' object for the function)
        this.ctx = navigator.credentials;

        // the default object to pass to makeCredential, to be modified with modify() for various tests
        let challengeBytes = new Uint8Array(16);
        window.crypto.getRandomValues(challengeBytes);
        this.testObject = cloneObject(createCredentialDefaultArgs);
        // cloneObject can't clone the BufferSource in user.id, so let's recreate it.
        this.testObject.options.publicKey.user.id = new Uint8Array(16);
        this.testObject.options.publicKey.challenge = challengeBytes;

        // how to order the properties of testObject when passing them to makeCredential
        this.argOrder = [
            "options"
        ];

        // enable the constructor to modify the default testObject
        // would prefer to do this in the super class, but have to call super() before using `this.*`
        if (arguments.length) this.modify(...arguments);
    }

    validateRet(ret) {
        validatePublicKeyCredential(ret);
        validateAuthenticatorAttestationResponse(ret.response);
    }
}

/**
 * GetCredentialsTest
 *
 * tests the WebAuthn navigator.credentials.get() interface
 */
class GetCredentialsTest extends TestCase {
    constructor(...args) {
        // initialize the parent class
        super();

        // the function to be tested
        this.testFunction = navigator.credentials.get;
        // the context to call the test function with (i.e. - the 'this' object for the function)
        this.ctx = navigator.credentials;

        // default arguments
        let challengeBytes = new Uint8Array(16);
        window.crypto.getRandomValues(challengeBytes);
        this.testObject = cloneObject(getCredentialDefaultArgs);
        this.testObject.options.publicKey.challenge = challengeBytes;

        // how to order the properties of testObject when passing them to makeCredential
        this.argOrder = [
            "options"
        ];

        this.credentialPromiseList = [];

        // set to true to pass an empty allowCredentials list to credentials.get
        this.isResidentKeyTest = false;

        // enable the constructor to modify the default testObject
        // would prefer to do this in the super class, but have to call super() before using `this.*`
        if (arguments.length) {
            if (args.cred instanceof Promise) this.credPromise = args.cred;
            else if (typeof args.cred === "object") this.credPromise = Promise.resolve(args.cred);
            delete args.cred;
            this.modify(...arguments);
        }
    }

    addCredential(arg) {
        // if a Promise was passed in, add it to the list
        if (arg instanceof Promise) {
            this.credentialPromiseList.push(arg);
            return this;
        }

        // if a credential object was passed in, convert it to a Promise for consistency
        if (typeof arg === "object") {
            this.credentialPromiseList.push(Promise.resolve(arg));
            return this;
        }

        // if no credential specified then create one
        var p = createCredential();
        this.credentialPromiseList.push(p);

        return this;
    }

    testSetup(desc) {
        if (!this.credentialPromiseList.length) {
            throw new Error("Attempting list without defining credential to test");
        }

        return Promise.all(this.credentialPromiseList)
            .then((credList) => {
                var idList = credList.map((cred) => {
                    return {
                        id: cred.rawId,
                        transports: ["usb", "nfc", "ble"],
                        type: "public-key"
                    };
                });
                if (!this.isResidentKeyTest) {
                    this.testObject.options.publicKey.allowCredentials = idList;
                }
                // return super.test(desc);
            })
            .catch((err) => {
                throw Error(err);
            });
    }

    validateRet(ret) {
        validatePublicKeyCredential(ret);
        validateAuthenticatorAssertionResponse(ret.response);
    }

    setIsResidentKeyTest(isResidentKeyTest) {
        this.isResidentKeyTest = isResidentKeyTest;
        return this;
    }
}

/**
 * converts a uint8array to base64 url-safe encoding
 * based on similar function in resources/utils.js
 */
function base64urlEncode(array) {
  let string = String.fromCharCode.apply(null, array);
  let result = btoa(string);
  return result.replace(/=+$/g, '').replace(/\+/g, "-").replace(/\//g, "_");
}
/**
 * runs assertions against a PublicKeyCredential object to ensure it is properly formatted
 */
function validatePublicKeyCredential(cred) {
    // class
    assert_class_string(cred, "PublicKeyCredential", "Expected return to be instance of 'PublicKeyCredential' class");
    // id
    assert_idl_attribute(cred, "id", "should return PublicKeyCredential with id attribute");
    assert_readonly(cred, "id", "should return PublicKeyCredential with readonly id attribute");
    // rawId
    assert_idl_attribute(cred, "rawId", "should return PublicKeyCredential with rawId attribute");
    assert_readonly(cred, "rawId", "should return PublicKeyCredential with readonly rawId attribute");
    assert_equals(cred.id, base64urlEncode(new Uint8Array(cred.rawId)), "should return PublicKeyCredential with id attribute set to base64 encoding of rawId attribute");

    // type
    assert_idl_attribute(cred, "type", "should return PublicKeyCredential with type attribute");
    assert_equals(cred.type, "public-key", "should return PublicKeyCredential with type 'public-key'");
}

/**
 * runs assertions against a AuthenticatorAttestationResponse object to ensure it is properly formatted
 */
function validateAuthenticatorAttestationResponse(attr) {
    // class
    assert_class_string(attr, "AuthenticatorAttestationResponse", "Expected credentials.create() to return instance of 'AuthenticatorAttestationResponse' class");

    // clientDataJSON
    assert_idl_attribute(attr, "clientDataJSON", "credentials.create() should return AuthenticatorAttestationResponse with clientDataJSON attribute");
    assert_readonly(attr, "clientDataJSON", "credentials.create() should return AuthenticatorAttestationResponse with readonly clientDataJSON attribute");
    // TODO: clientDataJSON() and make sure fields are correct

    // attestationObject
    assert_idl_attribute(attr, "attestationObject", "credentials.create() should return AuthenticatorAttestationResponse with attestationObject attribute");
    assert_readonly(attr, "attestationObject", "credentials.create() should return AuthenticatorAttestationResponse with readonly attestationObject attribute");
    // TODO: parseAuthenticatorData() and make sure flags are correct
}

/**
 * runs assertions against a AuthenticatorAssertionResponse object to ensure it is properly formatted
 */
function validateAuthenticatorAssertionResponse(assert) {
    // class
    assert_class_string(assert, "AuthenticatorAssertionResponse", "Expected credentials.create() to return instance of 'AuthenticatorAssertionResponse' class");

    // clientDataJSON
    assert_idl_attribute(assert, "clientDataJSON", "credentials.get() should return AuthenticatorAssertionResponse with clientDataJSON attribute");
    assert_readonly(assert, "clientDataJSON", "credentials.get() should return AuthenticatorAssertionResponse with readonly clientDataJSON attribute");
    // TODO: clientDataJSON() and make sure fields are correct

    // signature
    assert_idl_attribute(assert, "signature", "credentials.get() should return AuthenticatorAssertionResponse with signature attribute");
    assert_readonly(assert, "signature", "credentials.get() should return AuthenticatorAssertionResponse with readonly signature attribute");

    // authenticatorData
    assert_idl_attribute(assert, "authenticatorData", "credentials.get() should return AuthenticatorAssertionResponse with authenticatorData attribute");
    assert_readonly(assert, "authenticatorData", "credentials.get() should return AuthenticatorAssertionResponse with readonly authenticatorData attribute");
    // TODO: parseAuthenticatorData() and make sure flags are correct
}

function defaultAuthenticatorArgs() {
  return {
    protocol: 'ctap1/u2f',
    transport: 'usb',
    hasResidentKey: false,
    hasUserVerification: false,
    isUserVerified: false,
  };
}

function standardSetup(cb, options = {}) {
  // Setup an automated testing environment if available.
  let authenticatorArgs = Object.assign(defaultAuthenticatorArgs(), options);
  window.test_driver.add_virtual_authenticator(authenticatorArgs)
      .then(authenticator => {
        cb();
        // XXX add a subtest to clean up the virtual authenticator since
        // testharness does not support waiting for promises on cleanup.
        promise_test(
            () =>
                window.test_driver.remove_virtual_authenticator(authenticator),
            'Clean up the test environment');
      })
      .catch(error => {
        if (error !==
            'error: Action add_virtual_authenticator not implemented') {
          throw error;
        }
        // The protocol is not available. Continue manually.
        cb();
      });
}

// virtualAuthenticatorPromiseTest runs |testCb| in a promise_test with a
// virtual authenticator set up before and destroyed after the test, if the
// virtual testing API is available. In manual tests, setup and teardown is
// skipped.
function virtualAuthenticatorPromiseTest(
    testCb, options = {}, name = 'Virtual Authenticator Test') {
  let authenticatorArgs = Object.assign(defaultAuthenticatorArgs(), options);
  promise_test(async t => {
    try {
      let authenticator =
          await window.test_driver.add_virtual_authenticator(authenticatorArgs);
      t.add_cleanup(
          () => window.test_driver.remove_virtual_authenticator(authenticator));
    } catch (error) {
      if (error !== 'error: Action add_virtual_authenticator not implemented') {
        throw error;
      }
    }
    return testCb(t);
  }, name);
}

function bytesEqual(a, b) {
  if (a instanceof ArrayBuffer) {
    a = new Uint8Array(a);
  }
  if (b instanceof ArrayBuffer) {
    b = new Uint8Array(b);
  }
  if (a.byteLength != b.byteLength) {
    return false;
  }
  for (let i = 0; i < a.byteLength; i++) {
    if (a[i] != b[i]) {
      return false;
    }
  }
  return true;
}

// Compares two PublicKeyCredentialUserEntity objects.
function userEntityEquals(a, b) {
  return bytesEqual(a.id, b.id) && a.name == b.name && a.displayName == b.displayName;
}

// Asserts that `actual` and `expected`, which are both JSON types, are equal.
// The object key order is ignored for comparison.
function assertJsonEquals(actual, expected, optMsg) {
  // Returns a copy of `jsonObj`, which must be a JSON type, with object keys
  // recursively sorted in lexicographic order; or simply `jsonObj` if it is not
  // an instance of Object.
  function deepSortKeys(jsonObj) {
    if (jsonObj instanceof Array) {
      return Array.from(jsonObj, (x) => { return deepSortKeys(x); })
    }
    if (typeof jsonObj !== 'object' || jsonObj === null ||
      jsonObj.__proto__.constructor !== Object ||
      Object.keys(jsonObj).length === 0) {
      return jsonObj;
    }
    return Object.keys(jsonObj).sort().reduce((acc, key) => {
      acc[key] = deepSortKeys(jsonObj[key]);
      return acc;
    }, {});
  }

  assert_equals(
    JSON.stringify(deepSortKeys(actual)),
    JSON.stringify(deepSortKeys(expected)), optMsg);
}
