import invariant from "invariant";
import { BigNumber } from "bignumber.js";
import { reduce, filter, map } from "rxjs/operators";
import flatMap from "lodash/flatMap";
import omit from "lodash/omit";
import { InvalidAddress, RecipientRequired, AmountRequired } from "@ledgerhq/errors";
import { fromAccountRaw, toAccountRaw, decodeAccountId, encodeAccountId, flattenAccounts, isAccountBalanceUnconfirmed, } from "../../account";
import { getCryptoCurrencyById } from "../../currencies";
import { getOperationAmountNumber } from "../../operation";
import { fromTransactionRaw, toTransactionRaw, toTransactionStatusRaw } from "../../transaction";
import { getAccountBridge, getCurrencyBridge } from "../../bridge";
import { mockDeviceWithAPDUs, releaseMockDevice } from "./mockDevice";
import { firstValueFrom } from "rxjs";
const warnDev = process.env.CI ? (..._args) => { } : (...msg) => console.warn(...msg);
// FIXME move out into DatasetTest to be defined in
const blacklistOpsSumEq = {
    currencies: ["ripple", "ethereum", "tezos", "assethub_polkadot"],
    impls: ["mock"],
};
function expectBalanceIsOpsSum(a) {
    expect(a.balance).toEqual(a.operations.reduce((sum, op) => sum.plus(getOperationAmountNumber(op)), new BigNumber(0)));
}
const defaultSyncConfig = {
    paginationConfig: {},
    blacklistedTokenIds: ["ethereum/erc20/ampleforth", "ethereum/erc20/steth"],
};
export function syncAccount(bridge, account, syncConfig = defaultSyncConfig) {
    return firstValueFrom(bridge.sync(account, syncConfig).pipe(reduce((a, f) => f(a), account)));
}
export function testBridge(data) {
    // covers all bridges through many different accounts
    // to test the common shared properties of bridges.
    const accountsRelated = [];
    const currenciesRelated = [];
    const { implementations, currencies } = data;
    for (const currencyId of Object.keys(currencies)) {
        const currencyData = currencies[currencyId];
        const currency = getCryptoCurrencyById(currencyId);
        currenciesRelated.push({
            currencyData,
            currency,
        });
        const accounts = currencyData.accounts || [];
        for (const accountData of accounts) {
            for (const impl of implementations) {
                if (accountData.implementations && !accountData.implementations.includes(impl)) {
                    continue;
                }
                const accountRaw = {
                    ...accountData.raw,
                    id: encodeAccountId({
                        ...decodeAccountId(accountData.raw.id),
                        type: impl,
                    }),
                };
                accountsRelated.push({
                    currencyData,
                    accountData,
                    accountRaw,
                    impl,
                    currency,
                });
            }
        }
    }
    const accountsFoundInScanAccountsMap = {};
    currenciesRelated.forEach(({ currencyData, currency }) => {
        const bridge = getCurrencyBridge(currency);
        const scanAccounts = async (apdus) => {
            const deviceId = await mockDeviceWithAPDUs(apdus, currencyData.mockDeviceOptions);
            try {
                const accounts = await firstValueFrom(bridge
                    .scanAccounts({
                    currency,
                    deviceId,
                    syncConfig: defaultSyncConfig,
                })
                    .pipe(filter(e => e.type === "discovered"), map(e => e.account), reduce((all, a) => all.concat(a), [])));
                return accounts;
            }
            catch (e) {
                console.error(e.message);
                throw e;
            }
            finally {
                releaseMockDevice(deviceId);
            }
        };
        const scanAccountsCaches = {};
        const scanAccountsCached = apdus => scanAccountsCaches[apdus] || (scanAccountsCaches[apdus] = scanAccounts(apdus));
        describe(currency.id + " currency bridge", () => {
            const { scanAccounts, FIXME_ignoreAccountFields, FIXME_ignoreOperationFields, FIXME_ignorePreloadFields, } = currencyData;
            test("functions are defined", () => {
                expect(typeof bridge.scanAccounts).toBe("function");
                expect(typeof bridge.preload).toBe("function");
                expect(typeof bridge.hydrate).toBe("function");
            });
            if (FIXME_ignorePreloadFields !== true) {
                test("preload and rehydrate", async () => {
                    const data1 = (await bridge.preload(currency)) || {};
                    const data1filtered = omit(data1, FIXME_ignorePreloadFields || []);
                    bridge.hydrate(data1filtered, currency);
                    if (data1filtered) {
                        const serialized1 = JSON.parse(JSON.stringify(data1filtered));
                        bridge.hydrate(serialized1, currency);
                        expect(serialized1).toBeDefined();
                        const data2 = (await bridge.preload(currency)) || {};
                        const data2filtered = omit(data2, FIXME_ignorePreloadFields || []);
                        if (data2filtered) {
                            bridge.hydrate(data2filtered, currency);
                            expect(data1filtered).toMatchObject(data2filtered);
                            const serialized2 = JSON.parse(JSON.stringify(data2filtered));
                            expect(serialized1).toMatchObject(serialized2);
                            bridge.hydrate(serialized2, currency);
                        }
                    }
                });
            }
            if (scanAccounts) {
                if (FIXME_ignoreOperationFields && FIXME_ignoreOperationFields.length) {
                    warnDev(currency.id +
                        " is ignoring operation fields: " +
                        FIXME_ignoreOperationFields.join(", "));
                }
                if (FIXME_ignoreAccountFields && FIXME_ignoreAccountFields.length) {
                    warnDev(currency.id + " is ignoring account fields: " + FIXME_ignoreAccountFields.join(", "));
                }
                describe("scanAccounts", () => {
                    scanAccounts.forEach(sa => {
                        // we start running the scan accounts in parallel!
                        test(sa.name, async () => {
                            const accounts = await scanAccountsCached(sa.apdus);
                            accounts.forEach(a => {
                                accountsFoundInScanAccountsMap[a.id] = a;
                            });
                            if (!sa.unstableAccounts) {
                                const raws = flatMap(accounts, a => {
                                    const main = toAccountRaw(a);
                                    if (!main.subAccounts)
                                        return [main];
                                    return [{ ...main, subAccounts: [] }, ...main.subAccounts];
                                });
                                const heads = raws.map(a => {
                                    const copy = omit(a, [
                                        "operations",
                                        "lastSyncDate",
                                        "creationDate",
                                        "blockHeight",
                                        "balanceHistory",
                                        "balanceHistoryCache",
                                    ].concat(FIXME_ignoreAccountFields || []));
                                    return copy;
                                });
                                const ops = raws.map(({ operations }) => operations
                                    .slice(0)
                                    .sort((a, b) => a.id.localeCompare(b.id))
                                    .map(op => {
                                    const copy = omit(op, ["date"].concat(FIXME_ignoreOperationFields || []));
                                    return copy;
                                }));
                                expect(heads).toMatchSnapshot();
                                expect(ops).toMatchSnapshot();
                            }
                            const testFn = sa.test;
                            if (testFn) {
                                await testFn(expect, accounts, bridge);
                            }
                        });
                        test("estimateMaxSpendable is between 0 and account balance", async () => {
                            const accounts = await scanAccountsCached(sa.apdus);
                            for (const account of accounts) {
                                const accountBridge = getAccountBridge(account);
                                const estimation = await accountBridge.estimateMaxSpendable({
                                    account,
                                });
                                expect(estimation.gte(0)).toBe(true);
                                if (!(account.spendableBalance.lt(0) && estimation.eq(0))) {
                                    expect(estimation.lte(account.spendableBalance)).toBe(true);
                                }
                                for (const sub of account.subAccounts || []) {
                                    const estimation = await accountBridge.estimateMaxSpendable({
                                        parentAccount: account,
                                        account: sub,
                                    });
                                    expect(estimation.gte(0)).toBe(true);
                                    expect(estimation.lte(sub.balance)).toBe(true);
                                }
                            }
                        });
                        test("no unconfirmed account", async () => {
                            const accounts = await scanAccountsCached(sa.apdus);
                            for (const account of flattenAccounts(accounts)) {
                                expect({
                                    id: account.id,
                                    unconfirmed: isAccountBalanceUnconfirmed(account),
                                }).toEqual({
                                    id: account.id,
                                    unconfirmed: false,
                                });
                            }
                        });
                        test("creationDate is correct", async () => {
                            const accounts = await scanAccountsCached(sa.apdus);
                            for (const account of flattenAccounts(accounts)) {
                                if (account.operations.length) {
                                    const op = account.operations[account.operations.length - 1];
                                    if (account.creationDate.getTime() > op.date.getTime()) {
                                        warnDev(`OP ${op.id} have date=${op.date.toISOString()} older than account.creationDate=${account.creationDate.toISOString()}`);
                                    }
                                    expect(account.creationDate.getTime()).not.toBeGreaterThan(op.date.getTime());
                                }
                            }
                        });
                    });
                });
            }
            const currencyDataTest = currencyData.test;
            if (currencyDataTest) {
                test(currency.id + " specific test", () => currencyDataTest(expect, bridge));
            }
        });
        const accounts = currencyData.accounts || [];
        if (accounts.length) {
            const accountsInScan = [];
            const accountsNotInScan = [];
            accounts.forEach(({ raw }) => {
                if (accountsFoundInScanAccountsMap[raw.id]) {
                    accountsInScan.push(raw.id);
                }
                else {
                    accountsNotInScan.push(raw.id);
                }
            });
            if (accountsInScan.length === 0) {
                warnDev(`/!\\ CURRENCY '${currency.id}' define accounts that are NOT in scanAccounts. please add at least one account that is from scanAccounts. This helps testing scanned accounts are fine and it also help performance.`);
            }
            if (accountsNotInScan.length === 0) {
                warnDev(`/!\\ CURRENCY '${currency.id}' define accounts that are ONLY in scanAccounts. please add one account that is NOT from scanAccounts. This helps covering the "recovering from xpub" mecanism.`);
            }
        }
    });
    accountsRelated
        .map(({ accountRaw, ...rest }) => {
        let accountPromise;
        let bridgePromise;
        let accountSyncedPromise;
        // lazy eval so we don't run this yet
        const getAccount = () => accountPromise || (accountPromise = fromAccountRaw(accountRaw));
        const getBridge = async () => {
            if (!bridgePromise) {
                bridgePromise = getAccount().then(account => {
                    const bridge = getAccountBridge(account, null);
                    if (!bridge)
                        throw new Error("no bridge for " + account.id);
                    return bridge;
                });
            }
            return bridgePromise;
        };
        const getSynced = async () => {
            if (!accountSyncedPromise) {
                const account = await getAccount();
                const bridge = await getBridge();
                accountSyncedPromise = syncAccount(bridge, account);
            }
            return accountSyncedPromise;
        };
        const currency = rest.currency;
        return {
            getSynced,
            getBridge,
            getAccount,
            initialAccountRaw: accountRaw,
            initialAccountId: accountRaw.id,
            initialAccountCurrency: currency,
            ...rest,
        };
    })
        .forEach(arg => {
        const { getSynced, getBridge, getAccount, initialAccountId, initialAccountCurrency, accountData, impl, } = arg;
        const makeTest = (name, fn) => {
            if (accountData.FIXME_tests && accountData.FIXME_tests.some(r => name.match(r))) {
                warnDev("FIXME test was skipped. " +
                    name +
                    " for " +
                    initialAccountCurrency.name +
                    " (" +
                    initialAccountId +
                    ")");
                return;
            }
            test(name, fn);
        };
        describe(impl + " bridge on account " + initialAccountCurrency.name + " (" + initialAccountId + ")", () => {
            describe("sync", () => {
                makeTest("succeed", async () => {
                    const account = await getSynced();
                    expect(fromAccountRaw(toAccountRaw(account))).toBeDefined();
                });
                if (impl !== "mock") {
                    const accFromScanAccounts = accountsFoundInScanAccountsMap[initialAccountId];
                    if (accFromScanAccounts) {
                        makeTest("matches the same account from scanAccounts", async () => {
                            const acc = await getSynced();
                            expect(acc).toMatchObject(accFromScanAccounts);
                        });
                    }
                }
                makeTest("account have no NaN values", async () => {
                    const account = await getSynced();
                    [account, ...(account.subAccounts || [])].forEach(a => {
                        expect(a.balance.isNaN()).toBe(false);
                        expect(a.operations.find(op => op.value.isNaN())).toBe(undefined);
                        expect(a.operations.find(op => op.fee.isNaN())).toBe(undefined);
                    });
                });
                if (!blacklistOpsSumEq.currencies.includes(initialAccountCurrency.id) &&
                    !blacklistOpsSumEq.impls.includes(impl)) {
                    makeTest("balance is sum of ops", async () => {
                        const account = await getSynced();
                        expectBalanceIsOpsSum(account);
                        if (account.subAccounts) {
                            account.subAccounts.forEach(expectBalanceIsOpsSum);
                        }
                    });
                    makeTest("balance and spendableBalance boundaries", async () => {
                        const account = await getSynced();
                        expect(account.balance).toBeInstanceOf(BigNumber);
                        expect(account.spendableBalance).toBeInstanceOf(BigNumber);
                        expect(account.balance.lt(0)).toBe(false);
                        expect(account.spendableBalance.lt(0)).toBe(false);
                        expect(account.spendableBalance.lte(account.balance)).toBe(true);
                    });
                }
                makeTest("existing operations object refs are preserved", async () => {
                    const account = await getSynced();
                    const count = Math.floor(account.operations.length / 2);
                    const operations = account.operations.slice(count);
                    const copy = {
                        ...account,
                        operations,
                        blockHeight: 0,
                    };
                    const bridge = await getBridge();
                    const synced = await syncAccount(bridge, copy);
                    expect(synced.operations.length).toBe(account.operations.length);
                    // same ops are restored
                    expect(synced.operations).toEqual(account.operations);
                    if (initialAccountId.startsWith("ethereumjs"))
                        return; // ethereumjs seems to have a bug on this, we ignore because the impl will be dropped.
                    // existing ops are keeping refs
                    synced.operations.slice(count).forEach((op, i) => {
                        expect(op).toStrictEqual(operations[i]);
                    });
                });
                makeTest("pendingOperations are cleaned up", async () => {
                    const account = await getSynced();
                    if (account.operations.length) {
                        const operations = account.operations.slice(1);
                        const pendingOperations = [account.operations[0]];
                        const copy = {
                            ...account,
                            operations,
                            pendingOperations,
                            blockHeight: 0,
                        };
                        const bridge = await getBridge();
                        const synced = await syncAccount(bridge, copy);
                        // same ops are restored
                        expect(synced.operations).toEqual(account.operations);
                        // pendingOperations is empty
                        expect(synced.pendingOperations).toEqual([]);
                    }
                });
                makeTest("there are no Operation dups (by id)", async () => {
                    const account = await getSynced();
                    const seen = {};
                    account.operations.forEach(op => {
                        expect(seen[op.id]).toBeUndefined();
                        seen[op.id] = op.id;
                    });
                });
            });
            describe("createTransaction", () => {
                makeTest("empty transaction is an object with empty recipient and zero amount", async () => {
                    const account = await getAccount();
                    const bridge = await getBridge();
                    expect(bridge.createTransaction(account)).toMatchObject({
                        amount: new BigNumber(0),
                        recipient: "",
                    });
                });
                makeTest("empty transaction is equals to itself", async () => {
                    const account = await getAccount();
                    const bridge = await getBridge();
                    expect(bridge.createTransaction(account)).toEqual(bridge.createTransaction(account));
                });
                makeTest("empty transaction correctly serialize", async () => {
                    const account = await getAccount();
                    const bridge = await getBridge();
                    const t = bridge.createTransaction(account);
                    expect(fromTransactionRaw(toTransactionRaw(t))).toEqual(t);
                });
                makeTest("transaction with amount and recipient correctly serialize", async () => {
                    const account = await getSynced();
                    const bridge = await getBridge();
                    const t = {
                        ...bridge.createTransaction(account),
                        amount: new BigNumber(1000),
                        recipient: account.freshAddress,
                    };
                    expect(fromTransactionRaw(toTransactionRaw(t))).toEqual(t);
                });
            });
            describe("updateTransaction", () => {
                // stability: function called twice will return the same object reference
                // (=== convergence so we can stop looping, typically because transaction will be a hook effect dependency of prepareTransaction)
                async function expectStability(t, patch) {
                    const bridge = await getBridge();
                    const t2 = bridge.updateTransaction(t, patch);
                    const t3 = bridge.updateTransaction(t2, patch);
                    expect(t2).toBe(t3);
                }
                makeTest("ref stability on empty transaction", async () => {
                    const account = await getSynced();
                    const bridge = await getBridge();
                    const tx = bridge.createTransaction(account);
                    await expectStability(tx, {});
                });
                makeTest("ref stability on self transaction", async () => {
                    const account = await getSynced();
                    const bridge = await getBridge();
                    const tx = bridge.createTransaction(account);
                    await expectStability(tx, {
                        amount: new BigNumber(1000),
                        recipient: account.freshAddress,
                    });
                });
            });
            describe("prepareTransaction", () => {
                // stability: function called twice will return the same object reference
                // (=== convergence so we can stop looping, typically because transaction will be a hook effect dependency of prepareTransaction)
                async function expectStability(account, t) {
                    const bridge = await getBridge();
                    let t2 = await bridge.prepareTransaction(account, t);
                    let t3 = await bridge.prepareTransaction(account, t2);
                    t2 = omit(t2, arg.currencyData.IgnorePrepareTransactionFields || []);
                    t3 = omit(t3, arg.currencyData.IgnorePrepareTransactionFields || []);
                    expect(t2).toStrictEqual(t3);
                }
                makeTest("ref stability on empty transaction", async () => {
                    const account = await getSynced();
                    const bridge = await getBridge();
                    await expectStability(account, bridge.createTransaction(account));
                });
                makeTest("ref stability on self transaction", async () => {
                    const account = await getSynced();
                    const bridge = await getBridge();
                    await expectStability(account, {
                        ...bridge.createTransaction(account),
                        amount: new BigNumber(1000),
                        recipient: account.freshAddress,
                    });
                });
                makeTest("can be run in parallel and all yield same results", async () => {
                    const account = await getSynced();
                    const bridge = await getBridge();
                    const t = {
                        ...bridge.createTransaction(account),
                        amount: new BigNumber(1000),
                        recipient: account.freshAddress,
                    };
                    const stable = await bridge.prepareTransaction(account, t);
                    const first = omit(await bridge.prepareTransaction(account, stable), arg.currencyData.IgnorePrepareTransactionFields || []);
                    const concur = await Promise.all(Array(3)
                        .fill(null)
                        .map(() => bridge.prepareTransaction(account, stable)));
                    concur.forEach(r => {
                        r = omit(r, arg.currencyData.IgnorePrepareTransactionFields || []);
                        expect(r).toEqual(first);
                    });
                });
            });
            describe("getTransactionStatus", () => {
                makeTest("can be called on an empty transaction", async () => {
                    const account = await getSynced();
                    const bridge = await getBridge();
                    const t = {
                        ...bridge.createTransaction(account),
                        feePerByte: new BigNumber(0.0001),
                    };
                    const s = await bridge.getTransactionStatus(account, t);
                    expect(s).toBeDefined();
                    expect(s.errors).toHaveProperty("recipient");
                    expect(s).toHaveProperty("totalSpent");
                    expect(s.totalSpent).toBeInstanceOf(BigNumber);
                    expect(s).toHaveProperty("estimatedFees");
                    expect(s.estimatedFees).toBeInstanceOf(BigNumber);
                    expect(s).toHaveProperty("amount");
                    expect(s.amount).toBeInstanceOf(BigNumber);
                    expect(s.amount).toEqual(new BigNumber(0));
                });
                makeTest("can be called on an empty prepared transaction", async () => {
                    const bridge = await getBridge();
                    const account = await getSynced();
                    const t = await bridge.prepareTransaction(account, {
                        ...bridge.createTransaction(account),
                        feePerByte: new BigNumber(0.0001),
                    });
                    const s = await bridge.getTransactionStatus(account, t);
                    expect(s).toBeDefined(); // FIXME i'm not sure if we can establish more shared properties
                });
                makeTest("Default empty recipient have a recipientError", async () => {
                    const bridge = await getBridge();
                    const account = await getSynced();
                    const t = {
                        ...bridge.createTransaction(account),
                        feePerByte: new BigNumber(0.0001),
                    };
                    const status = await bridge.getTransactionStatus(account, t);
                    expect(status.errors.recipient).toBeInstanceOf(RecipientRequired);
                });
                makeTest("invalid recipient have a recipientError", async () => {
                    const bridge = await getBridge();
                    const account = await getSynced();
                    const t = {
                        ...bridge.createTransaction(account),
                        feePerByte: new BigNumber(0.0001),
                        recipient: "invalidADDRESS",
                    };
                    const status = await bridge.getTransactionStatus(account, t);
                    expect(status.errors.recipient).toBeInstanceOf(InvalidAddress);
                });
                makeTest("Default empty amount has an amount error", async () => {
                    const bridge = await getBridge();
                    const account = await getSynced();
                    const t = await bridge.prepareTransaction(account, {
                        ...bridge.createTransaction(account),
                        feePerByte: new BigNumber(0.0001),
                    });
                    const status = await bridge.getTransactionStatus(account, t);
                    expect(status.errors.amount).toBeInstanceOf(AmountRequired);
                });
                const accountDataTest = accountData.test;
                if (accountDataTest) {
                    makeTest("account specific test", async () => {
                        const bridge = await getBridge();
                        return accountDataTest(expect, await getSynced(), bridge);
                    });
                }
                (accountData.transactions || []).forEach(({ name, transaction, expectedStatus, apdus, testSignedOperation, test: testFn }) => {
                    makeTest("transaction " + name, async () => {
                        const bridge = await getBridge();
                        const account = await getSynced();
                        let t = typeof transaction === "function"
                            ? transaction(bridge.createTransaction(account), account, bridge)
                            : transaction;
                        t = await bridge.prepareTransaction(account, {
                            feePerByte: new BigNumber(0.0001),
                            ...t,
                        });
                        const s = await bridge.getTransactionStatus(account, t);
                        if (expectedStatus) {
                            const es = typeof expectedStatus === "function"
                                ? expectedStatus(account, t, s)
                                : expectedStatus;
                            const { errors, warnings } = es;
                            // we match errors and warnings
                            errors && expect(s.errors).toMatchObject(errors);
                            warnings && expect(s.warnings).toMatchObject(warnings);
                            // now we match rest of fields but using the raw version for better readability
                            const restRaw = toTransactionStatusRaw({
                                ...s,
                                ...es,
                            }, account.currency.family);
                            delete restRaw.errors;
                            delete restRaw.warnings;
                            for (const k in restRaw) {
                                if (!(k in es)) {
                                    delete restRaw[k];
                                }
                            }
                            expect(toTransactionStatusRaw(s, account.currency.family)).toMatchObject(restRaw);
                        }
                        if (testFn) {
                            await testFn(expect, t, s, bridge);
                        }
                        if (Object.keys(s.errors).length === 0) {
                            const { subAccountId } = t;
                            const { subAccounts } = account;
                            const inferSubAccount = () => {
                                invariant(subAccounts, "sub accounts available");
                                const a = subAccounts.find(a => a.id === subAccountId);
                                invariant(a, "sub account not found");
                                return a;
                            };
                            const obj = subAccountId
                                ? {
                                    transaction: t,
                                    account: inferSubAccount(),
                                    parentAccount: account,
                                }
                                : {
                                    transaction: t,
                                    account: account,
                                };
                            if ((typeof t.mode !== "string" || t.mode === "send") &&
                                t.model &&
                                t.model.kind !== "stake.createAccount") {
                                const estimation = await bridge.estimateMaxSpendable(obj);
                                expect(estimation.gte(0)).toBe(true);
                                expect(estimation.lte(obj.account.balance)).toBe(true);
                                if (t.useAllAmount) {
                                    expect(estimation.toString()).toBe(s.amount.toString());
                                }
                            }
                        }
                        if (apdus && impl !== "mock") {
                            const deviceId = await mockDeviceWithAPDUs(apdus);
                            try {
                                const signedOperation = await firstValueFrom(bridge
                                    .signOperation({
                                    account,
                                    deviceId,
                                    transaction: t,
                                })
                                    .pipe(filter((e) => e.type === "signed"), map((e) => e.signedOperation)));
                                if (testSignedOperation) {
                                    await testSignedOperation(expect, signedOperation, account, t, s, bridge);
                                }
                            }
                            finally {
                                releaseMockDevice(deviceId);
                            }
                        }
                    });
                });
            });
            describe("signOperation and broadcast", () => {
                makeTest("method is available on bridge", async () => {
                    const bridge = await getBridge();
                    expect(typeof bridge.signOperation).toBe("function");
                    expect(typeof bridge.broadcast).toBe("function");
                }); // NB for now we are not going farther because most is covered by bash tests
            });
        });
    });
}
//# sourceMappingURL=bridge.js.map