import { assert } from "../assert.js";
import { BitSet } from "../binary/BitSet.js";
import { ceilPowerOfTwo } from "../binary/operations/ceilPowerOfTwo.js";

/**
 * Capacity of a bucket
 * @type {number}
 */
const BUCKET_SIZE = 4; // how many "tags"/"fingerprints" a single bucket can hold

/**
 * How many times to attempt insertion
 * @type {number}
 */
const INSERT_ATTEMPTS = 8;

let random_counter = 0;

/**
 *
 * @param {number} fpp false positive probability
 * @returns {number}
 */
function computeBitsPerTagForFPP(fpp) {
    return Math.ceil(Math.log2(1 / fpp + 3));
}

/**
 *
 * @param {number} max_keys
 * @param {number} bucket_size
 * @returns {number}
 */
function computeBucketsNeeded(max_keys, bucket_size) {
    return ceilPowerOfTwo(max_keys / bucket_size);
}


/**
 *
 * @param {number} count
 * @returns {number}
 */
function createMaskFromBitCount(count) {
    let r = 0;

    for (let i = 0; i < count; i++) {
        r |= 1 << i;
    }

    return r;
}

/**
 * Space-efficient probabilistic data structure that allows testing whether a key is included in the set or not
 * Provides definite "not in set" answer and a probabilistic "is in set" answer, certainty probability of which can be controlled
 * @see https://en.wikipedia.org/wiki/Cuckoo_filter
 * @description Inspired by MGunlogson's CuckooFilter4J library
 */
export class CuckooFilter {
    /**
     *
     * @param {number} fpp False Positive Probability, this is the expected chance that filter will return a false-positive
     * @param {number} maxKeys Expected number of entries in the filter for the given fpp, beyond this value fpp will sharply decrease
     */
    constructor({
                    fpp = 0.05,
                    maxKeys = 1000
                } = {}) {

        // validate parameters
        assert.greaterThan(fpp, 0, "fpp");
        assert.lessThan(fpp, 1, "fpp");

        assert.isNonNegativeInteger(maxKeys, "maxKeys");
        assert.greaterThanOrEqual(maxKeys, 1, "maxKeys");

        /**
         * how many bits are needed per tag
         * @readonly
         * @type {number}
         */
        this.bits_per_tag = computeBitsPerTagForFPP(fpp);

        /**
         * @readonly
         * @type {number}
         */
        this.tag_hash_mask = createMaskFromBitCount(this.bits_per_tag);

        /**
         * compute how many buckets we'll need
         * @readonly
         * @type {number}
         */
        this.bucket_count = computeBucketsNeeded(maxKeys, BUCKET_SIZE);

        /**
         * Actual data storage
         * @readonly
         * @type {BitSet}
         */
        this.data = new BitSet();

        /**
         * @private
         * @type {number}
         */
        this.__count = 0;

        /**
         *
         * @type {boolean}
         * @private
         */
        this.__victim_exists = false;
        /**
         *
         * @type {number}
         * @private
         */
        this.__victim_tag = 0;

        /**
         *
         * @type {number}
         * @private
         */
        this.__victim_index = 0;
    }

    /**
     *
     * @returns {number}
     */
    get count() {
        return this.__count;
    }

    /**
     *
     * @param {number} bucket_index
     * @param {number} position_in_bucket
     * @returns {number}
     * @private
     */
    __getTagOffset(bucket_index, position_in_bucket) {
        return (bucket_index * BUCKET_SIZE + position_in_bucket) * this.bits_per_tag;
    }

    /**
     * Check if a tag in a given position in a bucket matches the tag you passed
     * it. Faster than regular read because it stops checking if it finds a
     * non-matching bit.
     * @param {number} bucket_index
     * @param {number} position_in_bucket
     * @param {number} tag
     * @returns {boolean}
     * @private
     */
    __checkTag(bucket_index, position_in_bucket, tag) {
        const tag_start_index = this.__getTagOffset(bucket_index, position_in_bucket);

        const bitsPerTag = this.bits_per_tag;

        const data = this.data;

        for (let i = 0; i < bitsPerTag; i++) {
            const tag_bit_mask = 1 << i;
            const tag_bit_set = (tag & tag_bit_mask) !== 0;
            const bucket_slot_bit_set = data.get(tag_start_index + i);

            if (bucket_slot_bit_set !== tag_bit_set) {
                return false;
            }
        }

        return true;
    }

    /**
     *
     * @param {number} bucket_index
     * @param {number} tag
     * @returns {boolean}
     * @private
     */
    __insertToBucket(bucket_index, tag) {
        for (let i = 0; i < BUCKET_SIZE; i++) {
            if (this.__checkTag(bucket_index, i, 0)) {
                this.__writeTagNoClear(bucket_index, i, tag);
                return true;
            }
        }

        return false;
    }

    /**
     *
     * @param {number} bucket_index
     * @param {number} pos_in_bucket
     * @param {number} tag
     * @private
     */
    __writeTagNoClear(bucket_index, pos_in_bucket, tag) {
        const tag_start_index = this.__getTagOffset(bucket_index, pos_in_bucket);

        const bitsPerTag = this.bits_per_tag;
        for (let i = 0; i < bitsPerTag; i++) {
            const tag_bit_mask = 1 << i;
            if ((tag & tag_bit_mask) !== 0) {
                this.data.set(tag_start_index + i, true);
            }
        }
    }

    /**
     * Deletes (clears) a tag at a specific bucket index and position
     * @param {number} bucket_index
     * @param {number} position_in_bucket
     * @private
     */
    __deleteTag(bucket_index, position_in_bucket) {
        const tag_start_index = this.__getTagOffset(bucket_index, position_in_bucket);

        this.data.clearRange(tag_start_index, tag_start_index + this.bits_per_tag);
    }

    /**
     * Deletes an item from the table if it is found in the bucket
     * @param {number} bucket_index
     * @param {number} tag
     * @returns {boolean}
     * @private
     */
    __deleteFromBucket(bucket_index, tag) {
        for (let i = 0; i < BUCKET_SIZE; i++) {
            if (this.__checkTag(bucket_index, i, tag)) {
                this.__deleteTag(bucket_index, i);

                return true;
            }
        }

        return false;
    }

    /**
     * Similar to checkTag() except it counts the number of matches in the
     * buckets.
     * @param {number} bucket_index_a First bucket to look for tag in
     * @param {number} bucket_index_b Second bucket to look for tag in
     * @param {number} tag
     * @returns {number}
     */
    __countTag(bucket_index_a, bucket_index_b, tag) {
        let tag_count = 0;

        for (let i = 0; i < BUCKET_SIZE; i++) {
            if (this.__checkTag(bucket_index_a, i, tag)) {
                tag_count++;
            }
            if (this.__checkTag(bucket_index_b, i, tag)) {
                tag_count++;
            }
        }

        return tag_count;
    }

    /**
     * Finds a tag if present in two buckets.
     * @param {number} bucket_index_a First bucket to look for tag in
     * @param {number} bucket_index_b Second bucket to look for tag in
     * @param {number} tag
     * @returns {boolean}
     */
    __findTag(bucket_index_a, bucket_index_b, tag) {
        for (let i = 0; i < BUCKET_SIZE; i++) {
            if (
                this.__checkTag(bucket_index_a, i, tag)
                || this.__checkTag(bucket_index_b, i, tag)
            ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Reads a tag and sets the bits to a new tag at same time for max
     * speedification
     * @param {number} bucket_index
     * @param {number} position_in_bucket
     * @param {number} new_tag
     * @returns {number} existing tag
     * @private
     */
    __readTagAndSet(bucket_index, position_in_bucket, new_tag) {
        let existing_tag = 0;
        const tag_start_index = this.__getTagOffset(bucket_index, position_in_bucket);
        const bit_per_tag = this.bits_per_tag;

        const data = this.data;

        for (let i = 0; i < bit_per_tag; i++) {
            const bit_offset = tag_start_index + i;
            const bit_mask = 1 << i;
            if ((new_tag & bit_mask) !== 0) {
                if (data.getAndSet(bit_offset)) {
                    existing_tag |= bit_mask;
                }
            } else {
                if (data.getAndClear(bit_offset)) {
                    existing_tag |= bit_mask;
                }
            }
        }

        return existing_tag;
    }

    /**
     * Replaces a tag in a random position in the given bucket and returns the
     * tag that was replaced.
     * @param {number} bucket_index bucket index
     * @param {number} tag
     * @returns {number} replaced tag
     * @private
     */
    __swapRandomTagInBucket(bucket_index, tag) {

        /*
         Instead of using a good random number generator,
         we use a cheat with a counter and a XOR hash, it's a good enough approximation
         The advantage is that it's fast and predictable
         */
        const random_int32 = random_counter ^ tag;
        random_counter++;

        const bucket_slot_index = random_int32 & (BUCKET_SIZE - 1);

        return this.__readTagAndSet(bucket_index, bucket_slot_index, tag);
    }

    /**
     * Given an item hash, provide a bucket index where the said item should go
     * @param {number} hash
     * @returns {number}
     * @private
     */
    __compute_bucket_index_from_hash(hash) {
        // use hash bits that are not used for tag
        const bucket_hash_bits = hash >> this.bits_per_tag;

        /*
        table_->num_buckets is always a power of two, so modulo can be replaced
        with bitwise-and:
         */
        return bucket_hash_bits & (this.bucket_count - 1);
    }

    /**
     * For cuckoo filtering to work correctly, this method must produce reversible result,
     * given tag X and input index A, result must always be B and,
     * given tag X and input index B, result must always be A.
     * see the original paper for more in-depth explanation
     * @see "Cuckoo Filter: Practically Better Than Bloom" by Bin Fan et al. 2014
     * @param {number} index
     * @param {number} tag
     * @returns {number}
     * @private
     */
    __compute_alt_bucket_index(index, tag) {
        const bucket_hash = index ^ (tag * 0x1a85ec53);
        // since bucket counts are all power of two, we can avoid expensive MOD operation and use bitwise AND instead
        return bucket_hash & (this.bucket_count - 1);
    }

    /**
     *
     * @param {number} hash
     * @returns {number}
     * @private
     */
    __compute_tag_from_hash(hash) {
        const tag_mask = this.tag_hash_mask;

        let tag = hash & tag_mask;

        if (tag === 0) {
            // make sure tag is never 0, which is a reserved value for empty slot
            tag = 1;
        }

        return tag;
    }

    /**
     * Puts an element into this {@link CuckooFilter}. Ensures that subsequent
     * invocations of {@link #mightContain(Object)} with the same element will
     * always return `true`.
     *
     * Note that the filter should be considered full after insertion failure.
     * Further inserts <i>may</i> fail, although deleting items can also make
     * the filter usable again.
     *
     * Also note that inserting the same item more than 8 times will cause an
     * insertion failure.
     *
     * @param {number} hash hash of item to insert
     * @returns {boolean} `true` if the cuckoo filter inserts this item successfully.
     *         Returns `false` if insertion failed.
     */
    insert(hash) {
        const current_tag = this.__compute_tag_from_hash(hash);
        const current_bucket_index = this.__compute_bucket_index_from_hash(hash);
        const alt_bucket_index = this.__compute_alt_bucket_index(current_bucket_index, current_tag);

        if (
            this.__insertToBucket(current_bucket_index, current_tag)
            || this.__insertToBucket(alt_bucket_index, current_tag)
        ) {
            this.__count++;
            return true;
        }

        if (this.__victim_exists) {
            // victim slot is already in use, kick look is unsafe to use, fail
            // filter is full
            return false;
        }

        // insertion failed, we're going into the "kick" loop

        let target_index = alt_bucket_index;
        let target_tag = current_tag;

        for (let i = 0; i < INSERT_ATTEMPTS; i++) {
            target_tag = this.__swapRandomTagInBucket(target_index, target_tag);
            target_index = this.__compute_alt_bucket_index(target_index, target_tag);

            if (this.__insertToBucket(target_index, target_tag)) {
                // done
                this.__count++;
                return true;
            }
        }

        // record victim
        this.__victim_exists = true;
        this.__victim_index = target_index;
        this.__victim_tag = target_tag;
        this.__count++;

        // we're okay, displaced element is recorded
        return true;
    }

    __insertVictimIfExists() {
        if (!this.__victim_exists) {
            return;
        }

        const index = this.__victim_index;
        const tag = this.__victim_tag;

        if (
            this.__insertToBucket(index, tag)
            || this.__insertToBucket(this.__compute_alt_bucket_index(index, tag), tag)) {
            // victim was housed
            this.__victim_exists = false;
        }
    }

    /**
     *
     * @param {number} i1
     * @param {number} i2
     * @param {number} tag
     * @returns {boolean}
     * @private
     */
    __checkVictim(i1, i2, tag) {
        return this.__victim_tag === tag
            && (
                this.__victim_index === i1
                || this.__victim_index === i2
            );
    }

    /**
     * Deletes an element from this {@link CuckooFilter}. In most cases you
     * should only delete items that have been previously added to the filter.
     * Attempting to delete non-existent items may successfully delete the wrong
     * item in the filter, causing a false negative. False negatives are defined
     * as( {@link #mightContain(Object)} returning false for an item that
     * <i>has</i> been added to the filter. Deleting non-existent items doesn't
     * otherwise adversely affect the state of the filter, so attempting to
     * delete items that <i>may not</i> have been inserted is fine if false
     * negatives are acceptable. The false-delete rate is similar to the false
     * positive rate. False deletes can also cause the
     * {@link #approximateCount(Object)} to return both lower and higher than
     * the real count
     *
     * @param {number} hash
     * @returns {boolean} `true` if the cuckoo filter deleted this item successfully.
     *         Returns `false` if the item was not found.
     */
    remove(hash) {
        const tag = this.__compute_tag_from_hash(hash);
        const i1 = this.__compute_bucket_index_from_hash(hash);
        const i2 = this.__compute_alt_bucket_index(i1, tag);

        if (this.__deleteFromBucket(i1, tag) || this.__deleteFromBucket(i2, tag)) {
            this.__count--;

            // if victim exists, try to give a home
            this.__insertVictimIfExists();

            return true;
        }

        // check if it's the victim
        if (
            this.__victim_exists
            && this.__checkVictim(i1, i2, tag)
        ) {
            // victim match, erase the victim
            this.__victim_exists = false;
            this.__count--;
            return true;
        }

        return false;
    }


    /**
     * Returns `true` if the element <i>might</i> have been put in this
     * Cuckoo filter, `false` if this is <i>definitely</i> not the case.
     *
     * @param {number} hash
     * @returns {boolean}
     */
    mightContain(hash) {
        const tag = this.__compute_tag_from_hash(hash);
        const i1 = this.__compute_bucket_index_from_hash(hash);
        const i2 = this.__compute_alt_bucket_index(i1, tag);

        return this.__findTag(i1, i2, tag)
            || (this.__victim_exists && this.__victim_tag === tag && (this.__victim_index === i1 || this.__victim_index === i2)) //check the victim
            ;
    }
}
