import { asWriteable } from '../Types';
import { BimChangeBase } from './bim-api-client';

/**
 * Represents the access levels a {@link Permission} can be given.
 */
export enum Access {
    /** No access. */
    none = 'none',
    /** Can view. */
    view = 'view',
    /** Can add. */
    add = 'add',
    /** Can edit. */
    edit = 'edit',
    /** Can delete. */
    delete = 'delete'
}

/**
 * Predefined permission masks. More will be added in the future.
 * Why not an enum? Enums can only use numbers but we need bigints sinec
 * the permission mask can have more bits than what number supports.
 */
const permissionMask = {
    [Access.none]: BigInt(0),
    [Access.view]: BigInt(1),
    [Access.add]: BigInt(2),
    [Access.edit]: BigInt(4),
    [Access.delete]: BigInt(8)
} as const;

/** string definitions for the predefined permission masks. */
export type PermissionIndex = keyof typeof permissionMask;

const permissionMasksInStringOutputOrder = (() => {
    return Object.entries(permissionMask)
        .filter((i): i is [PermissionIndex, bigint] => typeof i[1] === 'bigint')
        .sort(([, a], [, b]) => {
            // sort must return a number and subtracting bigints
            // will yield a bigint. Hence we use the if statements instead.
            // Converting a bigint to a number will lead to loss of precision.
            if (a > b) return 1;
            if (a < b) return -1;
            return 0;
        });
})();

/**
 * Represents a permission. Use together with {@link Permissions}.
 * If using the string representation ('view', 'add' etc) then it represents a single
 * permission. If using the bigint representation then it is assumed to be a bitmask
 * and can represent all permissions.
 */
export type Permission = PermissionIndex | keyof typeof Access | bigint;

/**
 * Defines how {@link Permissions} is represented in JSON.
 */
export type PermissionJson = string;

/**
 * Represents a set of permissions.
 */
export class Permissions {
    /**
     * Permission bitmask.
     * Since permissions are Uint64 bitmasks it must be represented
     * as a bigint. Watch out for `number` type coercion if
     * reading this property directly.
     * While it is possible to use bitwise operations on the mask, to determine
     * the permissions, it is better to use the provided methods such as {@link hasAny} and {@link hasAll}.
     */
    public readonly mask: bigint = permissionMask.none;

    /**
     * Constructor
     * @param permissions Permission instances to create the {@link Permissions} instance from.
     */
    public constructor(...permissions: Permission[]) {
        this.mask = Permissions.join(permissions);
    }

    /**
     * Create a {@link Permissions} instance from a permission mask represented
     * as a bigint string. This is how permissions are represented in JSON as it is
     * not possible to send 64bit integers in JSON.
     * @param permission If a real bigint string then it is converted to a {@link Permissions} instance.
     * If it is a `falsy` returns a {@link Permissions} instance without any permissions.
     * If it is a `truthy` but not a bigint string then a exception is generated.
     * @returns {@link Permissions} instance.
     */
    public static fromJsonObject(permission: PermissionJson): Permissions {
        return !permission ? new Permissions() : new Permissions(BigInt(permission));
    }

    /**
     * Copies the permissions defined by `permissions` to the `dst`.permissions property.
     * @param permissions Permission to copy to `dst`.permissions property.
     * @param dst Change to use.
     * @returns Reference to `dst`.
     */
    public static copyToRef<T extends Pick<BimChangeBase, 'permissions'>>(
        permissions: { json: PermissionJson } | Permission | Permissions,
        dst: T
    ): T {
        let p: Permissions;
        if (permissions instanceof Permissions) {
            p = new Permissions(permissions.mask);
        } else if (typeof permissions === 'bigint' || typeof permissions === 'string') {
            p = new Permissions(permissions);
        } else {
            p = Permissions.fromJsonObject(permissions.json);
        }
        asWriteable(dst).permissions = p;
        return dst;
    }

    /**
     * Converts a number of {@link Permission} instances into a human readable string.
     * @param permissions Permissions to convert.
     * @returns Human readable string.
     */
    public static toString(...permissions: Permission[]): string {
        return new Permissions(Permissions.join(permissions)).toString();
    }

    /**
     * Join one or more permissions into a single numeric permission mask. Permissions are bitwise OR:ed together.
     * @param permissions Permissions to join.
     * @returns Numeric permission mask. Can be compared directly with {@link mask}.
     * Can also be used in constructor of {@link Permissions}.
     */
    public static join(...permissions: (Permission[] | Permission)[]): bigint {
        let mask = permissionMask.none;
        for (const permOrArray of permissions) {
            if (Array.isArray(permOrArray)) {
                for (const p of permOrArray) {
                    mask |= Permissions.toMask(p);
                }
            } else {
                mask |= Permissions.toMask(permOrArray);
            }
        }

        return mask;
    }

    /**
     * Checks if {@link mask} has any of the specified permissions.
     * @param permissions Permissions to compare with {@link mask}.
     * @returns `true` if {@link mask} holds any of the specified permissions.
     */
    public hasAny(...permissions: Permission[]): boolean {
        return (this.mask & Permissions.join(permissions)) !== permissionMask.none;
    }

    /**
     * Checks if {@link mask} has all of the specified permissions.
     * @param permissions Permissions to compare with {@link mask}.
     * @returns `true` if {@link mask} holds all of the specified permissions.
     */
    public hasAll(...permissions: Permission[]): boolean {
        const tmp = Permissions.join(permissions);
        return (this.mask & tmp) === tmp;
    }

    /**
     * Converts to a human readable string.
     * @returns Human readable string.
     */
    public toString(): string {
        const segments: string[] = [];

        for (const [name, mask] of permissionMasksInStringOutputOrder) {
            const isEnabled =
                (this.mask & mask) !== permissionMask.none ||
                (mask === permissionMask.none && this.mask === permissionMask.none);

            if (isEnabled) {
                segments.push(name);
            }
        }
        return segments.join('|');
    }

    /**
     * Converts the Permission object to a JSON representation.
     * @returns The JSON representation of the Permission object.
     */
    public toJson(): PermissionJson {
        return this.mask.toString();
    }

    private static toMask(perm: string | bigint): bigint {
        if (typeof perm === 'bigint') return perm;
        // if perm is string then it is either a PermissionIndex or a bigint in the form of a string. Anything else is
        // a error.
        if (typeof perm === 'string') return (permissionMask as any)[perm] ?? BigInt(perm);
        return permissionMask.none;
    }
}
