import { isEqual } from "lodash";
import {
    builderToMuid,
    ensure,
    generateTimestamp,
    dehydrate,
    matches,
    muidToTuple,
    sameData,
    unwrapValue,
    getActorId,
    muidTupleToString,
    muidTupleToMuid,
    verifyBundle,
    librariesReady,
    shorterHash,
    decryptMessage,
} from "./utils";
import { deleteDB, IDBPDatabase, openDB, IDBPTransaction } from "idb";
import {
    ActorId,
    AsOf,
    BroadcastFunc,
    BundleBytes,
    BundleInfo,
    BundleInfoTuple,
    Bytes,
    ChainStart,
    ClaimedChain,
    Clearance,
    Entry,
    Indexable,
    IndexedDbStoreSchema,
    ScalarKey,
    Medallion,
    Muid,
    MuidTuple,
    Removal,
    Timestamp,
    BundleView,
    KeyPair,
    Value,
    Placement,
} from "./typedefs";
import {
    extractContainerMuid,
    getStorageKey,
    extractMovement,
    buildPairLists,
    buildPointeeList,
    buildChainTracker,
    toStorageKey,
    bundleKeyToInfo,
    bundleInfoToKey,
    storageKeyToString,
} from "./store_utils";
import { HasMap } from "./HasMap";
import { Store } from "./Store";
import {
    Behavior,
    ChangeBuilder,
    EntryBuilder,
    BundleBuilder,
} from "./builders";
import { PromiseChainLock } from "./PromiseChainLock";
import { Retrieval } from "./Retrieval";

type Transaction = IDBPTransaction<
    IndexedDbStoreSchema,
    (
        | "trxns"
        | "chainInfos"
        | "activeChains"
        | "containers"
        | "removals"
        | "clearances"
        | "entries"
        | "identities"
        | "verifyKeys"
        | "secretKeys"
        | "symmetricKeys"
        | "accumulatorTotals"
    )[],
    "readwrite" | "readonly"
>;

if (eval("typeof indexedDB") === "undefined") {
    // ts-node has problems with typeof
    eval('require("fake-indexeddb/auto");'); // hide require from webpack
}

/**
 * Uses an indexedDb to implement the Store interface.  On the server side, this will
 * be done using a shim that is only an in-memory implementation of the IndexedDb API,
 * so the LogBackedStore should be used on the server for persistence.  Most of the time
 * uses of Gink should not need to call methods on the store directly, instead just
 * pass it into the Database (or SimpleServer, etc.).
 */
export class IndexedDbStore implements Store {
    ready: Promise<void>;
    private wrapped: IDBPDatabase<IndexedDbStoreSchema>;
    private transaction: Transaction | null = null;
    private countTrxns: number = 0;
    private trxnId: number = 0;
    private initialized = false;
    private processingLock = new PromiseChainLock();
    private lastCaller: string = "";
    private foundBundleCallBacks: BroadcastFunc[] = [];
    private pending: BundleInfo[] = [];
    private static readonly YEAR_2020 = new Date("2020-01-01").getTime() * 1000;

    constructor(
        indexedDbName: string,
        reset?: boolean,
        private keepingHistory = true,
    ) {
        this.ready = this.initialize(indexedDbName, reset);
    }

    async getBillionths(muid: Muid, asOf?: AsOf): Promise<bigint> {
        if (asOf) throw new Error("asOf not implemented for accumulators yet");
        const muidTuple = muidToTuple(muid);
        await this.ready;
        const trxn = this.getTransaction();
        const value = await trxn
            .objectStore("accumulatorTotals")
            .get(muidTuple);
        return value;
    }

    acquireChain(identity: string): Promise<BundleInfo | null> {
        return Promise.resolve(null);
    }

    private async initialize(
        indexedDbName: string,
        reset: boolean,
    ): Promise<void> {
        await librariesReady;
        if (reset) {
            await deleteDB(indexedDbName, {
                blocked() {
                    const msg = `Unable to delete IndexedDB database ${indexedDbName} !!!`;
                    throw new Error(msg);
                },
            });
        }
        this.wrapped = await openDB<IndexedDbStoreSchema>(indexedDbName, 1, {
            upgrade(
                db: IDBPDatabase<IndexedDbStoreSchema>,
                _oldVersion: number,
                _newVersion: number,
                _transaction,
            ) {
                // info(`upgrade, oldVersion:${oldVersion}, newVersion:${newVersion}`);
                /*
                     The object store for transactions will store the raw bytes received
                     for each transaction to avoid dropping unknown fields.  Since this
                     isn't a javascript object, we'll use
                     [timestamp, medallion] to keep transactions ordered in time.
                 */
                db.createObjectStore("trxns"); // a map from BundleKey to BundleBytes

                /*
                    Stores ChainInfo objects.
                    This will keep track of which transactions have been processed per chain.
                */
                db.createObjectStore("chainInfos", {
                    keyPath: ["medallion", "chainStart"],
                });

                /*
                    Keep track of active chains this instance can write to.
                    It stores objects with two keys: "medallion" and "chainStart",
                    which have value Medallion and ChainStart respectively.
                    This could alternatively be implemented with a keys being
                    medallions and values being chainStarts, but this is a little
                    easier because the getAll() interface is a bit nicer than
                    working with the cursor interface.
                */
                db.createObjectStore("activeChains", {
                    keyPath: ["claimTime"],
                });

                /*
                    Keep track of the identities of who started each chain.
                    key: [medallion, chainStart]
                    value: identity (string)
                    Not setting keyPath since [medallion, chainStart] can't be pulled from the value
                */
                db.createObjectStore("identities");
                db.createObjectStore("verifyKeys");
                db.createObjectStore("secretKeys");
                db.createObjectStore("symmetricKeys");

                db.createObjectStore("clearances", {
                    keyPath: ["containerId", "clearanceId"],
                });

                db.createObjectStore("containers"); // map from AddressTuple to ContainerBytes
                db.createObjectStore("accumulatorTotals");

                // the "removals" stores objects of type `Removal`
                const removals = db.createObjectStore("removals", {
                    keyPath: "removalId",
                });
                removals.createIndex("by-container-movement", [
                    "containerId",
                    "removalId",
                ]);
                removals.createIndex("by-removing", ["removing", "removalId"]);

                // The "entries" store has objects of type Entry (from typedefs)
                const entries = db.createObjectStore("entries", {
                    keyPath: "placementId",
                });
                entries.createIndex("by-container-key-placement", [
                    "containerId",
                    "storageKey",
                    "placementId",
                ]);
                entries.createIndex("by-container-name", [
                    "containerId",
                    "value",
                ]); // Useful for quickly looking up a container by its name

                // This index is used to find all properties that describe a particular container.
                entries.createIndex("by-key-placement", [
                    "storageKey",
                    "placementId",
                ]);

                // ideally the next three indexes would be partial indexes, covering only sequences and edges
                // it might be worth pulling them out into separate lookup tables.
                entries.createIndex("locations", ["entryId", "placementId"]);
                entries.createIndex("sources", [
                    "sourceList",
                    "storageKey",
                    "placementId",
                ]);
                entries.createIndex("targets", [
                    "targetList",
                    "storageKey",
                    "placementId",
                ]);
            },
        });
        this.initialized = true;
    }

    async getVerifyKey(chainInfo: [Medallion, ChainStart]): Promise<Bytes> {
        await this.ready;
        const wrappedTransaction = this.getTransaction();
        const verifyKey = await wrappedTransaction
            .objectStore("verifyKeys")
            .get(chainInfo);
        return verifyKey;
    }

    async saveKeyPair(keyPair: KeyPair): Promise<void> {
        await this.ready;
        const trxn = this.getTransaction();
        await trxn
            .objectStore("secretKeys")
            .put(keyPair.secretKey, keyPair.publicKey);
    }

    async pullKeyPair(publicKey: Bytes): Promise<KeyPair> {
        await this.ready;
        const trxn = this.getTransaction();
        const secretKey = await trxn.objectStore("secretKeys").get(publicKey);
        return { secretKey, publicKey };
    }

    async saveSymmetricKey(symmetricKey: Bytes): Promise<number> {
        if (symmetricKey.length !== 32) {
            throw new Error("symmetric key must be 32 bytes");
        }
        await this.ready;
        const keyId = shorterHash(symmetricKey);
        const trxn = this.getTransaction();
        await trxn.objectStore("symmetricKeys").put(symmetricKey, keyId);
        return keyId;
    }

    async getSymmetricKey(keyId: number, trxn?: Transaction): Promise<Bytes> {
        await this.ready;
        trxn = trxn ?? this.getTransaction();
        return await trxn.objectStore("symmetricKeys").get(keyId);
    }

    private clearTransaction(id: number) {
        if (id == this.trxnId) {
            // console.log(`clearing transaction number ${id}`);
            this.transaction = null;
        } else {
            // console.log(`already passed trxn: ${id}`);
        }
    }

    private getTransaction(): Transaction {
        const stackString = new Error().stack;
        const callerLine = stackString ? stackString.split("\n")[2] : "";
        if (this.transaction === null || this.lastCaller !== callerLine) {
            this.lastCaller = callerLine;
            this.countTrxns += 1;
            const id = (this.trxnId = this.countTrxns);
            // console.log(`starting transaction ${id}`)
            this.transaction = this.wrapped.transaction(
                [
                    "entries",
                    "clearances",
                    "removals",
                    "trxns",
                    "chainInfos",
                    "activeChains",
                    "containers",
                    "identities",
                    "verifyKeys",
                    "secretKeys",
                    "symmetricKeys",
                    "accumulatorTotals",
                ],
                "readwrite",
            );
            this.transaction.done.then(() => this.clearTransaction(id));
        }
        return this.transaction;
    }

    getTransactionCount(): number {
        return this.countTrxns;
    }

    async dropHistory(container?: Muid, before?: AsOf): Promise<void> {
        const beforeTs = before
            ? await this.asOfToTimestamp(before)
            : generateTimestamp();
        const trxn = this.wrapped.transaction(
            ["removals", "entries"],
            "readwrite",
        );
        let removalsCursor = await trxn
            .objectStore("removals")
            .openCursor(IDBKeyRange.upperBound([beforeTs]));
        if (container) {
            const containerTuple = muidToTuple(container);
            const range = IDBKeyRange.bound(
                [containerTuple, [0]],
                [containerTuple, [beforeTs]],
            );
            removalsCursor = await trxn
                .objectStore("removals")
                .index("by-container-movement")
                .openCursor(range);
        }
        while (removalsCursor) {
            await trxn
                .objectStore("entries")
                .delete(removalsCursor.value.removing);
            await removalsCursor.delete();
            removalsCursor = await removalsCursor.continue();
        }
        return trxn.done;
    }

    async stopHistory(): Promise<void> {
        this.keepingHistory = false;
        return this.dropHistory();
    }

    startHistory(): void {
        this.keepingHistory = true;
    }

    async close() {
        try {
            await this.ready;
        } finally {
            if (this.wrapped) {
                this.wrapped.close();
            }
        }
    }

    async getLocation(
        entry: Muid,
        asOf?: AsOf,
    ): Promise<Placement | undefined> {
        const asOfTs: Timestamp = asOf
            ? await this.asOfToTimestamp(asOf)
            : generateTimestamp();
        const trxn = this.wrapped.transaction(
            ["entries", "clearances", "removals"],
            "readonly",
        );
        const range = IDBKeyRange.bound(
            [muidToTuple(entry), [0]],
            [muidToTuple(entry), [Infinity]],
        );
        let cursor = await trxn
            .objectStore("entries")
            .index("locations")
            .openCursor(range, "prev");
        if (cursor && cursor.value) {
            const containerId = cursor.value.containerId;
            const placementId = cursor.value.placementId;
            const entryId = cursor.value.entryId;
            const lastClear = await this.getClearanceTime(
                trxn,
                containerId,
                asOfTs,
            );
            const removalLower = [entryId];
            const removalUpper = [entryId, [asOfTs]];
            const removalCursor = await trxn
                .objectStore("removals")
                .index("by-removing")
                .openCursor(
                    IDBKeyRange.bound(removalLower, removalUpper),
                    "prev",
                );
            const foundRemoval = removalCursor && removalCursor.value;

            if (lastClear > placementId[0] || foundRemoval) return undefined;

            return {
                container: containerId,
                key: cursor.value.storageKey,
                placement: placementId,
            };
        }
        return undefined;
    }

    private async asOfToTimestamp(asOf: AsOf): Promise<Timestamp> {
        if (asOf instanceof Date) {
            return asOf.getTime() * 1000;
        }
        if (asOf > IndexedDbStore.YEAR_2020) {
            return asOf;
        }
        if (asOf < 0 && asOf > -1000) {
            // Interpret as number of bundles in the past.
            let cursor = await this.wrapped
                .transaction("trxns", "readonly")
                .objectStore("trxns")
                .openCursor(undefined, "prev");
            let bundlesToTraverse = -asOf;
            for (; cursor; cursor = await cursor.continue()) {
                if (--bundlesToTraverse === 0) {
                    const tuple = <BundleInfoTuple>cursor.key;
                    return tuple[0];
                }
            }
            // Looking further back then we have bundles.
            throw new Error("no bundles that far back");
        }
        throw new Error(`don't know how to interpret asOf=${asOf}`);
    }

    async getClaimedChains(): Promise<Map<Medallion, ClaimedChain>> {
        if (!this.initialized) throw new Error("not initilized");
        const objectStore = this.wrapped
            .transaction("activeChains", "readonly")
            .objectStore("activeChains");
        const items = await objectStore.getAll();
        const result: Map<Medallion, ClaimedChain> = new Map();
        let lastTs = 0;
        for (let item of items) {
            if (item.claimTime < lastTs) throw new Error("claims not in order");
            lastTs = item.claimTime;
            result.set(item.medallion, item);
        }
        return result;
    }

    async getChainIdentity(
        chainInfo: [Medallion, ChainStart],
    ): Promise<string> {
        await this.ready;
        const wrappedTransaction = this.getTransaction();
        const identity = await wrappedTransaction
            .objectStore("identities")
            .get(chainInfo);
        return identity;
    }

    private async claimChain(
        medallion: Medallion,
        chainStart: ChainStart,
        actorId?: ActorId,
        transaction?: Transaction,
    ): Promise<ClaimedChain> {
        await this.ready;
        const wrappedTransaction = transaction ?? this.getTransaction();
        const claim = {
            chainStart,
            medallion,
            actorId: actorId || 0,
            claimTime: generateTimestamp(),
        };
        await wrappedTransaction.objectStore("activeChains").add(claim);
        return claim;
    }

    async getChainTracker(): Promise<HasMap> {
        await this.ready;
        const chainInfos = await this.getChainInfos();
        const chainTracker = buildChainTracker(chainInfos);
        return chainTracker;
    }

    private async getChainInfos(): Promise<Array<BundleInfo>> {
        await this.ready;
        return await this.getTransaction().objectStore("chainInfos").getAll();
    }

    addBundle(bundleView: BundleView, claimChain?: boolean): Promise<boolean> {
        if (!this.initialized) throw new Error("need to await on store.ready");
        return this.processingLock
            .acquireLock()
            .then(async (unlock) => {
                const trxn = this.getTransaction();
                let added: boolean;
                try {
                    added = await this.addBundleHelper(
                        trxn,
                        bundleView,
                        claimChain,
                    );
                } finally {
                    unlock();
                }
                return trxn.done.then(() => added);
            })
            .catch((e) => {
                throw e;
            });
    }

    private async addBundleHelper(
        trxn: Transaction,
        bundleView: BundleView,
        claimChain?: boolean,
    ): Promise<boolean> {
        const bundleInfo = bundleView.info;
        const bundleBuilder: BundleBuilder = bundleView.builder;
        const { timestamp, medallion, chainStart, priorTime } = bundleInfo;
        const oldChainInfo: BundleInfo = await trxn
            .objectStore("chainInfos")
            .get([medallion, chainStart]);
        if (oldChainInfo || priorTime) {
            if (oldChainInfo?.timestamp >= timestamp) {
                return false;
            }
            if (oldChainInfo?.timestamp !== priorTime) {
                //TODO(https://github.com/google/gink/issues/27): Need to explicitly close?
                throw new Error(
                    `missing ${JSON.stringify(bundleInfo)}, have ${JSON.stringify(oldChainInfo)}`,
                );
            }
            const priorHash = bundleBuilder.getPriorHash();
            if (
                !priorHash ||
                priorHash.length != 32 ||
                !sameData(priorHash, oldChainInfo.hashCode)
            )
                throw new Error("prior hash is invalid");
        }
        const identity = bundleBuilder.getIdentity();
        // If this is a new chain, save the identity & claim this chain
        if (claimChain) {
            ensure(
                bundleInfo.timestamp === bundleInfo.chainStart,
                "timestamp !== chainstart",
            );
            ensure(identity, "identity required to start a chain");
            await this.claimChain(
                bundleInfo.medallion,
                bundleInfo.chainStart,
                getActorId(),
                trxn,
            );
        }
        let verifyKey: Bytes;
        const chainInfo: [Medallion, ChainStart] = [
            bundleInfo.medallion,
            bundleInfo.chainStart,
        ];

        if (bundleInfo.chainStart === bundleInfo.timestamp) {
            ensure(identity, `identity required to start a chain`);
            await trxn.objectStore("identities").add(identity, chainInfo);
            verifyKey = bundleBuilder.getVerifyKey();
            await trxn.objectStore("verifyKeys").put(verifyKey, chainInfo);
        } else {
            ensure(
                !identity,
                `cannot have identity in non-chain-start bundle - ${identity}`,
            );
            verifyKey = await trxn.objectStore("verifyKeys").get(chainInfo);
        }
        verifyBundle(bundleView.bytes, verifyKey);
        await trxn.objectStore("chainInfos").put(bundleInfo);
        // Only timestamp and medallion are required for uniqueness, the others just added to make
        // the getNeededTransactions faster by not requiring parsing again.
        const bundleKey: BundleInfoTuple = bundleInfoToKey(bundleInfo);
        await trxn.objectStore("trxns").add(bundleView.bytes, bundleKey);
        // Decrypt bundle
        const encrypted = bundleBuilder.getEncrypted();
        let changesList: Array<ChangeBuilder>;
        if (encrypted) {
            const keyId = bundleBuilder.getKeyId();
            if (bundleBuilder.getChangesList().length > 0) {
                throw new Error(
                    "did not expect plain changes when using encryption",
                );
            }
            if (!keyId) {
                throw new Error("expected keyId with encrypted bundle");
            }
            const symmetricKey = ensure(
                await this.getSymmetricKey(keyId, trxn),
                "could not find symmetric key referenced in bundle",
            );
            const decrypted = decryptMessage(encrypted, symmetricKey);
            const innerBundleBuilder = <BundleBuilder>(
                BundleBuilder.deserializeBinary(decrypted)
            );
            changesList = innerBundleBuilder.getChangesList();
        }
        // Changes list will either come from getChangesList in an unencrypted bundle, or
        // getChangesList from the decrypted inner bundle.
        if (!changesList) changesList = bundleBuilder.getChangesList();
        for (let index = 0; index < changesList.length; index++) {
            const offset = index + 1;
            const changeBuilder = changesList[index];
            ensure(offset > 0);
            const changeAddressTuple: MuidTuple = [
                timestamp,
                medallion,
                offset,
            ];
            const changeAddress: Muid = { timestamp, medallion, offset };
            if (changeBuilder.hasContainer()) {
                const containerBytes = changeBuilder
                    .getContainer()
                    .serializeBinary();
                await trxn
                    .objectStore("containers")
                    .add(containerBytes, changeAddressTuple);
                continue;
            }
            if (changeBuilder.hasEntry()) {
                const entryBuilder: EntryBuilder = changeBuilder.getEntry();
                let containerId: MuidTuple = [0, 0, 0];
                if (entryBuilder.hasContainer()) {
                    containerId = extractContainerMuid(
                        entryBuilder,
                        bundleInfo,
                    );
                }
                const storageKey = getStorageKey(entryBuilder, changeAddress);
                const entryId: MuidTuple = [timestamp, medallion, offset];
                const behavior: Behavior = entryBuilder.getBehavior();
                const placementId: MuidTuple = entryId;
                let pointeeList = <Indexable[]>[];
                if (entryBuilder.hasPointee()) {
                    pointeeList = buildPointeeList(entryBuilder, bundleInfo);
                }
                let sourceList = <Indexable[]>[];
                let targetList = <Indexable[]>[];
                if (entryBuilder.hasPair()) {
                    [sourceList, targetList] = buildPairLists(
                        entryBuilder,
                        bundleInfo,
                    );
                }
                const value = entryBuilder.hasValue()
                    ? unwrapValue(entryBuilder.getValue())
                    : undefined;
                const expiry = entryBuilder.getExpiry() || undefined;
                const deletion = entryBuilder.getDeletion();
                const entry: Entry = {
                    behavior,
                    containerId,
                    storageKey,
                    entryId,
                    pointeeList,
                    value,
                    expiry,
                    deletion,
                    placementId,
                    sourceList,
                    targetList,
                };
                if (
                    !(
                        behavior === Behavior.SEQUENCE ||
                        behavior === Behavior.EDGE_TYPE ||
                        behavior === Behavior.ACCUMULATOR
                    )
                ) {
                    const range = IDBKeyRange.bound(
                        [containerId, storageKey],
                        [containerId, storageKey, placementId],
                    );
                    const search = await trxn
                        .objectStore("entries")
                        .index("by-container-key-placement")
                        .openCursor(range, "prev");
                    if (search) {
                        if (this.keepingHistory) {
                            const removal: Removal = {
                                removing: search.value.placementId,
                                removalId: placementId,
                                containerId: containerId,
                                dest: 0,
                                entryId: search.value.entryId,
                            };
                            await trxn.objectStore("removals").add(removal);
                        } else {
                            await trxn
                                .objectStore("entries")
                                .delete(search.value.placementId);
                        }
                    }
                }
                if (behavior === Behavior.ACCUMULATOR) {
                    let current = await trxn
                        .objectStore("accumulatorTotals")
                        .get(entry.containerId);
                    let total = BigInt(0);
                    if (typeof current === "bigint") {
                        total = total + current;
                    }
                    if (typeof entry.value === "bigint") {
                        total = total + entry.value;
                    }
                    await trxn
                        .objectStore("accumulatorTotals")
                        .put(total, entry.containerId);
                }
                await trxn.objectStore("entries").add(entry);
                continue;
            }
            if (changeBuilder.hasMovement()) {
                const movement = extractMovement(
                    changeBuilder,
                    bundleInfo,
                    offset,
                );
                const { entryId, movementId, containerId, dest, purge } =
                    movement;
                const range = IDBKeyRange.bound(
                    [entryId, [0]],
                    [entryId, [Infinity]],
                );
                const search = await trxn
                    .objectStore("entries")
                    .index("locations")
                    .openCursor(range, "prev");
                if (!search) {
                    continue; // Nothing found to remove.
                }
                const found: Entry = search.value;
                if (dest !== 0) {
                    const destEntry: Entry = {
                        behavior: found.behavior,
                        containerId: found.containerId,
                        storageKey: dest,
                        entryId: found.entryId,
                        pointeeList: found.pointeeList,
                        value: found.value,
                        expiry: found.expiry,
                        deletion: found.deletion,
                        placementId: movementId,
                        sourceList: found.sourceList,
                        targetList: found.targetList,
                    };
                    await trxn.objectStore("entries").add(destEntry);
                }
                if (purge || !this.keepingHistory) {
                    search.delete();
                } else {
                    const removal: Removal = {
                        containerId,
                        removalId: movementId,
                        dest,
                        entryId,
                        removing: found.placementId,
                    };
                    await trxn.objectStore("removals").add(removal);
                }
                continue;
            }
            if (changeBuilder.hasClearance()) {
                const clearanceBuilder = changeBuilder.getClearance();
                const container = builderToMuid(
                    clearanceBuilder.getContainer(),
                    { timestamp, medallion, offset },
                );
                const containerMuidTuple: MuidTuple = [
                    container.timestamp,
                    container.medallion,
                    container.offset,
                ];
                if (clearanceBuilder.getPurge()) {
                    // When purging, remove all entries from the container.
                    const onePast = [
                        container.timestamp,
                        container.medallion,
                        container.offset + 1,
                    ];
                    const range = IDBKeyRange.bound(
                        [containerMuidTuple],
                        [onePast],
                        false,
                        true,
                    );
                    let entriesCursor = await trxn
                        .objectStore("entries")
                        .index("by-container-key-placement")
                        .openCursor(range);
                    while (entriesCursor) {
                        await entriesCursor.delete();
                        entriesCursor = await entriesCursor.continue();
                    }
                    // When doing a purging clear, remove previous clearances for the container.
                    let clearancesCursor = await trxn
                        .objectStore("clearances")
                        .openCursor(range);
                    while (clearancesCursor) {
                        await clearancesCursor.delete();
                        clearancesCursor = await clearancesCursor.continue();
                    }
                    // When doing a purging clear, remove all removals for the container.
                    let removalsCursor = await trxn
                        .objectStore("removals")
                        .index("by-container-movement")
                        .openCursor(range);
                    while (removalsCursor) {
                        await removalsCursor.delete();
                        removalsCursor = await removalsCursor.continue();
                    }
                }
                const clearance: Clearance = {
                    containerId: containerMuidTuple,
                    clearanceId: changeAddressTuple,
                    purging: clearanceBuilder.getPurge(),
                };
                await trxn.objectStore("clearances").add(clearance);
                continue;
            }
            throw new Error("don't know how to apply this kind of change");
        }
        return true;
    }

    async getContainerBytes(address: Muid): Promise<Bytes | undefined> {
        const addressTuple = [
            address.timestamp,
            address.medallion,
            address.offset,
        ];
        return await this.wrapped
            .transaction("containers", "readonly")
            .objectStore("containers")
            .get(<MuidTuple>addressTuple);
    }

    async getEntryByKey(
        container?: Muid,
        key?: ScalarKey | Muid | [Muid, Muid],
        asOf?: AsOf,
    ): Promise<Entry | undefined> {
        const asOfTs = asOf ? await this.asOfToTimestamp(asOf) : Infinity;
        const desiredSrc = [
            container?.timestamp ?? 0,
            container?.medallion ?? 0,
            container?.offset ?? 0,
        ];
        const trxn = this.wrapped.transaction(
            ["clearances", "entries"],
            "readonly",
        );
        let clearanceTime: Timestamp = 0;
        const clearancesSearch = IDBKeyRange.bound(
            [desiredSrc],
            [desiredSrc, [asOfTs]],
        );
        const clearancesCursor = await trxn
            .objectStore("clearances")
            .openCursor(clearancesSearch, "prev");
        if (clearancesCursor) {
            clearanceTime = clearancesCursor.value.clearanceId[0];
        }

        let upperTuple = [asOfTs];
        const storageKey = toStorageKey(key);
        const lower = [desiredSrc];
        const upper = [desiredSrc, storageKey, upperTuple];
        const searchRange = IDBKeyRange.bound(lower, upper);
        const entriesCursor = await trxn
            .objectStore("entries")
            .index("by-container-key-placement")
            .openCursor(searchRange, "prev");
        if (entriesCursor) {
            const entry: Entry = entriesCursor.value;
            if (!sameData(entry.storageKey, storageKey)) {
                return undefined;
            }
            if (entry.placementId[0] < clearanceTime) {
                // a clearance happened after this thing was placed, so treat it as gone
                return undefined;
            }
            return entry;
        }
        return undefined;
    }

    async getClearanceTime(
        trxn: Transaction,
        muidTuple: MuidTuple,
        asOfTs: Timestamp,
    ): Promise<Timestamp> {
        const clearancesSearch = IDBKeyRange.bound(
            [muidTuple],
            [muidTuple, [asOfTs]],
        );
        const clearancesCursor = await trxn
            .objectStore("clearances")
            .openCursor(clearancesSearch, "prev");
        if (clearancesCursor) {
            return <Timestamp>clearancesCursor.value.clearanceId[0];
        }
        return <Timestamp>0;
    }

    async getKeyedEntries(
        container: Muid,
        asOf?: AsOf,
    ): Promise<Map<string, Entry>> {
        const asOfTs = asOf ? await this.asOfToTimestamp(asOf) : Infinity;
        const desiredSrc: MuidTuple = [
            container?.timestamp ?? 0,
            container?.medallion ?? 0,
            container?.offset ?? 0,
        ];
        const trxn = this.wrapped.transaction(
            ["clearances", "entries"],
            "readonly",
        );
        const clearanceTime = await this.getClearanceTime(
            <Transaction>(<unknown>trxn),
            desiredSrc,
            asOfTs,
        );
        const lower = [desiredSrc];
        const searchRange = IDBKeyRange.lowerBound(lower);
        let cursor = await trxn
            .objectStore("entries")
            .index("by-container-key-placement")
            .openCursor(searchRange, "next");
        const result = new Map();
        for (
            ;
            cursor && matches(cursor.key[0], desiredSrc);
            cursor = await cursor.continue()
        ) {
            const entry = <Entry>cursor.value;

            ensure(
                entry.behavior === Behavior.DIRECTORY ||
                    entry.behavior === Behavior.KEY_SET ||
                    entry.behavior === Behavior.GROUP ||
                    entry.behavior === Behavior.PAIR_SET ||
                    entry.behavior === Behavior.PAIR_MAP ||
                    entry.behavior === Behavior.PROPERTY,
            );
            const key = storageKeyToString(entry.storageKey);
            if (
                entry.entryId[0] < asOfTs &&
                entry.entryId[0] >= clearanceTime
            ) {
                if (entry.deletion) {
                    result.delete(key);
                } else {
                    result.set(key, entry);
                }
            }
        }
        return result;
    }

    async getEntriesBySourceOrTarget(
        vertex: Muid,
        source: boolean,
        asOf?: AsOf,
    ): Promise<Entry[]> {
        await this.ready;
        const asOfTs: Timestamp = asOf
            ? await this.asOfToTimestamp(asOf)
            : generateTimestamp() + 1;
        const indexable = dehydrate(vertex);
        const trxn = this.wrapped.transaction(
            ["clearances", "entries", "removals"],
            "readonly",
        );
        const clearanceTime = await this.getClearanceTime(
            <Transaction>(<unknown>trxn),
            indexable,
            asOfTs,
        );
        const lower = [[indexable], -Infinity];
        const upper = [[indexable], +Infinity];
        const searchRange = IDBKeyRange.bound(lower, upper);
        let entriesCursor = await trxn
            .objectStore("entries")
            .index(source ? "sources" : "targets")
            .openCursor(searchRange);
        const returning: Entry[] = [];
        const removals = trxn.objectStore("removals");
        for (; entriesCursor; entriesCursor = await entriesCursor.continue()) {
            const entry: Entry = entriesCursor.value;
            if (
                entry.placementId[0] >= asOfTs ||
                entry.placementId[0] < clearanceTime
            )
                continue;
            const removalsBound = IDBKeyRange.bound(
                [entry.placementId],
                [entry.placementId, [asOfTs]],
            );
            // TODO: This seek-per-entry isn't very efficient and should be a replaced with a scan.
            const removalsCursor = await removals
                .index("by-removing")
                .openCursor(removalsBound);
            if (!removalsCursor) returning.push(entry);
        }
        return returning;
    }

    /**
     * Returns entry data for a List.  Does it in a single pass rather than using an async generator
     * because if a user tried to await on something else between entries it would cause the IndexedDb
     * transaction to auto-close.
     * @param container to get entries for
     * @param through number to get, negative for starting from end
     * @param asOf show results as of a time in the past
     * @returns a promise of a list of ChangePairs
     */
    async getOrderedEntries(
        container: Muid,
        through = Infinity,
        asOf?: AsOf,
    ): Promise<Map<string, Entry>> {
        const asOfTs: Timestamp = asOf
            ? await this.asOfToTimestamp(asOf)
            : generateTimestamp() + 1;
        const containerId = [
            container?.timestamp ?? 0,
            container?.medallion ?? 0,
            container?.offset ?? 0,
        ];
        const lower = [containerId, 0];
        const upper = [containerId, asOfTs];
        const range = IDBKeyRange.bound(lower, upper);
        const trxn = this.wrapped.transaction(
            ["clearances", "entries", "removals"],
            "readonly",
        );

        let clearanceTime: Timestamp = 0;
        const clearancesSearch = IDBKeyRange.bound(
            [containerId],
            [containerId, [asOfTs]],
        );
        const clearancesCursor = await trxn
            .objectStore("clearances")
            .openCursor(clearancesSearch, "prev");
        if (clearancesCursor) {
            clearanceTime = clearancesCursor.value.clearanceId[0];
        }

        const entries = trxn.objectStore("entries");
        const removals = trxn.objectStore("removals");
        const returning = new Map<string, Entry>();
        let entriesCursor = await entries
            .index("by-container-key-placement")
            .openCursor(range, through < 0 ? "prev" : "next");
        const needed = through < 0 ? -through : through + 1;
        while (entriesCursor && returning.size < needed) {
            const entry: Entry = entriesCursor.value;
            if (entry.placementId[0] >= clearanceTime) {
                const removalsBound = IDBKeyRange.bound(
                    [entry.placementId],
                    [entry.placementId, [asOfTs]],
                );
                // TODO: This seek-per-entry isn't very efficient and should be a replaced with a scan.
                const removalsCursor = await removals
                    .index("by-removing")
                    .openCursor(removalsBound);
                if (!removalsCursor) {
                    const placementIdStr = muidTupleToString(entry.placementId);
                    const returningKey = `${entry.storageKey},${placementIdStr}`;
                    returning.set(returningKey, entry);
                }
            }
            entriesCursor = await entriesCursor.continue();
        }
        return returning;
    }

    async getEntryById(
        entryMuid: Muid,
        asOf?: AsOf,
    ): Promise<Entry | undefined> {
        const asOfTs: Timestamp = asOf
            ? await this.asOfToTimestamp(asOf)
            : generateTimestamp();
        const entryId = [
            entryMuid.timestamp ?? 0,
            entryMuid.medallion ?? 0,
            entryMuid.offset ?? 0,
        ];
        const entryRange = IDBKeyRange.bound(
            [entryId, [0]],
            [entryId, [asOfTs]],
        );
        const trxn = this.wrapped.transaction(
            ["entries", "removals", "clearances"],
            "readonly",
        );
        const entryCursor = await trxn
            .objectStore("entries")
            .index("locations")
            .openCursor(entryRange, "prev");
        if (!entryCursor) {
            return undefined;
        }
        const entry: Entry = entryCursor.value;
        const lastClear = await this.getClearanceTime(
            trxn,
            entry.containerId,
            asOfTs,
        );
        if (entry.placementId[0] >= lastClear) {
            const removalRange = IDBKeyRange.bound(
                [entry.placementId],
                [entry.placementId, [asOfTs]],
            );
            const removalCursor = await trxn
                .objectStore("removals")
                .index("by-removing")
                .openCursor(removalRange);

            if (!removalCursor) {
                return entry;
            }
        }
        return undefined;
    }

    async getContainersByName(name: string, asOf?: AsOf): Promise<Muid[]> {
        const asOfTs = asOf ? await this.asOfToTimestamp(asOf) : Infinity;
        const desiredSrc: MuidTuple = [-1, -1, Behavior.PROPERTY];
        const trxn = this.wrapped.transaction(
            ["clearances", "entries", "removals"],
            "readonly",
        );
        const clearanceTime = await this.getClearanceTime(
            <Transaction>(<unknown>trxn),
            desiredSrc,
            asOfTs,
        );
        const lower = [desiredSrc, name];
        const searchRange = IDBKeyRange.lowerBound(lower);
        let cursor = await trxn
            .objectStore("entries")
            .index("by-container-name")
            .openCursor(searchRange, "next");
        const result = [];

        for (
            ;
            cursor &&
            matches(cursor.key[0], desiredSrc) &&
            cursor.key[1] === name;
            cursor = await cursor.continue()
        ) {
            const entry = <Entry>cursor.value;
            ensure(entry.behavior === Behavior.PROPERTY);
            const range = IDBKeyRange.lowerBound([entry.entryId]);
            const removal = await trxn
                .objectStore("removals")
                .index("by-removing")
                .openCursor(range);
            if (
                removal &&
                removal.value.entryId.toString() === entry.entryId.toString()
            ) {
                continue;
            }
            let key: [number, number, number];
            if (
                Array.isArray(entry.storageKey) &&
                entry.storageKey.length === 3
            ) {
                key = entry.storageKey;
            }
            ensure(
                key,
                "Unexpected storageKey for property: " + entry.storageKey,
            );

            if (
                entry.entryId[0] < asOfTs &&
                entry.entryId[0] >= clearanceTime &&
                !entry.deletion
            ) {
                result.push(muidTupleToMuid(key));
            }
        }
        return result;
    }

    async getContainerProperties(
        containerMuid: Muid,
        asOf?: AsOf,
    ): Promise<Map<string, Value>> {
        const asOfTs: Timestamp = asOf
            ? await this.asOfToTimestamp(asOf)
            : generateTimestamp();
        const containerTuple = muidToTuple(containerMuid);

        const txn = this.wrapped.transaction(
            ["entries", "clearances"],
            "readonly",
        );
        const range = IDBKeyRange.bound(
            [containerTuple],
            [containerTuple, [asOfTs]],
        );
        let cursor = await txn
            .objectStore("entries")
            .index("by-key-placement")
            .openCursor(range);
        const result: Map<string, Value> = new Map();
        for (
            ;
            cursor &&
            Array.isArray(cursor.key[0]) &&
            isEqual(cursor.key[0], containerTuple);
            cursor = await cursor.continue()
        ) {
            const entry = <Entry>cursor.value;
            // TODO: think about a better way to do this. If there is a group that includes
            // this container, it may show up here. Though there could only be one entry per group,
            // so maybe not that big of a deal.
            if (!(entry.behavior === Behavior.PROPERTY)) continue;
            ensure(isEqual(entry.storageKey, containerTuple));
            if (
                !(
                    Array.isArray(entry.storageKey) &&
                    entry.storageKey.length === 3
                )
            ) {
                // This is also kinda just to keep typescript happy.
                // If storageKey is equal to containerMuid, this will never run.
                throw new Error("Unexpected storageKey for property");
            }
            const clearanceTime = await this.getClearanceTime(
                txn,
                muidToTuple(muidTupleToMuid(entry.containerId)),
                asOfTs,
            );
            if (
                entry.entryId[0] < asOfTs &&
                entry.entryId[0] >= clearanceTime
            ) {
                if (entry.deletion) {
                    result.delete(muidTupleToString(entry.containerId));
                } else {
                    result.set(
                        muidTupleToString(entry.containerId),
                        entry.value,
                    );
                }
            }
        }
        return result;
    }

    async getAllContainerTuples() {
        return await this.wrapped
            .transaction("containers", "readonly")
            .objectStore("containers")
            .getAllKeys();
    }

    // for debugging, not part of the api/interface
    async getAllEntryKeys() {
        return await this.wrapped
            .transaction("entries", "readonly")
            .objectStore("entries")
            .getAllKeys();
    }

    // for debugging, not part of the api/interface
    async getAllEntries(): Promise<Entry[]> {
        return await this.wrapped
            .transaction("entries", "readonly")
            .objectStore("entries")
            .getAll();
    }

    // for debugging, not part of the api/interface
    async getAllRemovals() {
        return await this.wrapped
            .transaction("removals", "readonly")
            .objectStore("removals")
            .getAll();
    }

    // Note the IndexedDB has problems when await is called on anything unrelated
    // to the current bundle, so its best if `callBack` doesn't await.
    async getBundles(callBack: (bundle: BundleView) => void) {
        await this.ready;

        // We loop through all bundles and send those the peer doesn't have.
        for (
            let cursor = await this.wrapped
                .transaction("trxns", "readonly")
                .objectStore("trxns")
                .openCursor();
            cursor;
            cursor = await cursor.continue()
        ) {
            const bundleKey = <BundleInfoTuple>cursor.key;
            const bundleInfo = bundleKeyToInfo(bundleKey);
            const bundleBytes: BundleBytes = cursor.value;
            callBack(new Retrieval({ bundleBytes, bundleInfo }));
        }
    }

    addFoundBundleCallBack(callback: BroadcastFunc): void {
        this.foundBundleCallBacks.push(callback);
    }
}
