import { DefinitionType, getPropertyDescriptor } from "./annotations.js";
import { Schema } from "./Schema.js";
import { getType, registeredTypes } from "./types/registry.js";
import { $decoder, $descriptors, $encoder, $fieldIndexesByViewTag, $numFields, $refTypeFieldIndexes, $track, $viewFieldIndexes } from "./types/symbols.js";
import { TypeContext } from "./types/TypeContext.js";

export type MetadataField = {
    type: DefinitionType,
    name: string,
    index: number,
    tag?: number,
    unreliable?: boolean,
    deprecated?: boolean,
};

export type Metadata =
    { [$numFields]: number; } & // number of fields
    { [$viewFieldIndexes]: number[]; } & // all field indexes with "view" tag
    { [$fieldIndexesByViewTag]: {[tag: number]: number[]}; } & // field indexes by "view" tag
    { [$refTypeFieldIndexes]: number[]; } & // all field indexes containing Ref types (Schema, ArraySchema, MapSchema, etc)
    { [field: number]: MetadataField; } & // index => field name
    { [field: string]: number; } & // field name => field metadata
    { [$descriptors]: { [field: string]: PropertyDescriptor } }  // property descriptors

export function getNormalizedType(type: any): DefinitionType  {
    if (Array.isArray(type)) {
        return { array: getNormalizedType(type[0]) };

    } else if (typeof (type['type']) !== "undefined") {
        return type['type'];

    } else if (isTSEnum(type)) {
        // Detect TS Enum type (either string or number)
        return Object.keys(type).every(key => typeof type[key] === "string")
            ? "string"
            : "number";

    } else if (typeof type === "object" && type !== null) {
        // Handle collection types
        const collectionType = Object.keys(type).find(k => registeredTypes[k] !== undefined);
        if (collectionType) {
            type[collectionType] = getNormalizedType(type[collectionType]);
            return type;
        }
    }
    return type;
}

function isTSEnum(_enum: any) {
    if (typeof _enum === 'function' && _enum[Symbol.metadata]) {
        return false;
    }

    const keys = Object.keys(_enum);
    const numericFields = keys.filter(k => /\d+/.test(k));

    // Check for number enum (has numeric keys and reverse mapping)
    if (numericFields.length > 0 && numericFields.length === (keys.length / 2) && _enum[_enum[numericFields[0]]] == numericFields[0]) {
        return true;
    }

    // Check for string enum (all values are strings and keys match values)
    if (keys.length > 0 && keys.every(key => typeof _enum[key] === 'string' && _enum[key] === key)) {
        return true;
    }

    return false;
}

export const Metadata = {

    addField(metadata: any, index: number, name: string, type: DefinitionType, descriptor?: PropertyDescriptor) {
        if (index > 64) {
            throw new Error(`Can't define field '${name}'.\nSchema instances may only have up to 64 fields.`);
        }

        metadata[index] = Object.assign(
            metadata[index] || {}, // avoid overwriting previous field metadata (@owned / @deprecated)
            {
                type: getNormalizedType(type),
                index,
                name,
            }
        );

        // create "descriptors" map
        Object.defineProperty(metadata, $descriptors, {
            value: metadata[$descriptors] || {},
            enumerable: false,
            configurable: true,
        });

        if (descriptor) {
            // for encoder
            metadata[$descriptors][name] = descriptor;
            metadata[$descriptors][`_${name}`] = {
                value: undefined,
                writable: true,
                enumerable: false,
                configurable: true,
            };
        } else {
            // for decoder
            metadata[$descriptors][name] = {
                value: undefined,
                writable: true,
                enumerable: true,
                configurable: true,
            };
        }

        // map -1 as last field index
        Object.defineProperty(metadata, $numFields, {
            value: index,
            enumerable: false,
            configurable: true
        });

        // map field name => index (non enumerable)
        Object.defineProperty(metadata, name, {
            value: index,
            enumerable: false,
            configurable: true,
        });

        // if child Ref/complex type, add to -4
        if (typeof (metadata[index].type) !== "string") {
            if (metadata[$refTypeFieldIndexes] === undefined) {
                Object.defineProperty(metadata, $refTypeFieldIndexes, {
                    value: [],
                    enumerable: false,
                    configurable: true,
                });
            }
            metadata[$refTypeFieldIndexes].push(index);
        }
    },

    setTag(metadata: Metadata, fieldName: string, tag: number) {
        const index = metadata[fieldName];
        const field = metadata[index];

        // add 'tag' to the field
        field.tag = tag;

        if (!metadata[$viewFieldIndexes]) {
            // -2: all field indexes with "view" tag
            Object.defineProperty(metadata, $viewFieldIndexes, {
                value: [],
                enumerable: false,
                configurable: true
            });

            // -3: field indexes by "view" tag
            Object.defineProperty(metadata, $fieldIndexesByViewTag, {
                value: {},
                enumerable: false,
                configurable: true
            });
        }

        metadata[$viewFieldIndexes].push(index);

        if (!metadata[$fieldIndexesByViewTag][tag]) {
            metadata[$fieldIndexesByViewTag][tag] = [];
        }

        metadata[$fieldIndexesByViewTag][tag].push(index);
    },

    setFields<T extends { new (...args: any[]): InstanceType<T> } = any>(target: T, fields: { [field in keyof InstanceType<T>]?: DefinitionType }) {
        // for inheritance support
        const constructor = target.prototype.constructor;
        TypeContext.register(constructor);

        const parentClass = Object.getPrototypeOf(constructor);
        const parentMetadata = parentClass && parentClass[Symbol.metadata];
        const metadata = Metadata.initialize(constructor);

        // Use Schema's methods if not defined in the class
        if (!constructor[$track]) { constructor[$track] = Schema[$track]; }
        if (!constructor[$encoder]) { constructor[$encoder] = Schema[$encoder]; }
        if (!constructor[$decoder]) { constructor[$decoder] = Schema[$decoder]; }
        if (!constructor.prototype.toJSON) { constructor.prototype.toJSON = Schema.prototype.toJSON; }

        //
        // detect index for this field, considering inheritance
        //
        let fieldIndex = metadata[$numFields] // current structure already has fields defined
            ?? (parentMetadata && parentMetadata[$numFields]) // parent structure has fields defined
            ?? -1; // no fields defined

        fieldIndex++;

        for (const field in fields) {
            const type = getNormalizedType(fields[field]);

            // FIXME: this code is duplicated from @type() annotation
            const complexTypeKlass = typeof(Object.keys(type)[0]) === "string" && getType(Object.keys(type)[0]);

            const childType = (complexTypeKlass)
                ? Object.values(type)[0]
                : type;

            Metadata.addField(
                metadata,
                fieldIndex,
                field,
                type,
                getPropertyDescriptor(`_${field}`, fieldIndex, childType, complexTypeKlass)
            );

            fieldIndex++;
        }

        return target;
    },

    isDeprecated(metadata: any, field: string) {
        return metadata[field].deprecated === true;
    },

    init(klass: any) {
        //
        // Used only to initialize an empty Schema (Encoder#constructor)
        // TODO: remove/refactor this...
        //
        const metadata = {};
        klass[Symbol.metadata] = metadata;
        Object.defineProperty(metadata, $numFields, {
            value: 0,
            enumerable: false,
            configurable: true,
        });
    },

    initialize(constructor: any) {
        const parentClass = Object.getPrototypeOf(constructor);
        const parentMetadata: Metadata = parentClass[Symbol.metadata];

        let metadata: Metadata = constructor[Symbol.metadata] ?? Object.create(null);

        // make sure inherited classes have their own metadata object.
        if (parentClass !== Schema && metadata === parentMetadata) {
            metadata = Object.create(null);

            if (parentMetadata) {
                //
                // assign parent metadata to current
                //
                Object.setPrototypeOf(metadata, parentMetadata);

                // $numFields
                Object.defineProperty(metadata, $numFields, {
                    value: parentMetadata[$numFields],
                    enumerable: false,
                    configurable: true,
                    writable: true,
                });

                // $viewFieldIndexes / $fieldIndexesByViewTag
                if (parentMetadata[$viewFieldIndexes] !== undefined) {
                    Object.defineProperty(metadata, $viewFieldIndexes, {
                        value: [...parentMetadata[$viewFieldIndexes]],
                        enumerable: false,
                        configurable: true,
                        writable: true,
                    });
                    Object.defineProperty(metadata, $fieldIndexesByViewTag, {
                        value: { ...parentMetadata[$fieldIndexesByViewTag] },
                        enumerable: false,
                        configurable: true,
                        writable: true,
                    });
                }

                // $refTypeFieldIndexes
                if (parentMetadata[$refTypeFieldIndexes] !== undefined) {
                    Object.defineProperty(metadata, $refTypeFieldIndexes, {
                        value: [...parentMetadata[$refTypeFieldIndexes]],
                        enumerable: false,
                        configurable: true,
                        writable: true,
                    });
                }

                // $descriptors
                Object.defineProperty(metadata, $descriptors, {
                    value: { ...parentMetadata[$descriptors] },
                    enumerable: false,
                    configurable: true,
                    writable: true,
                });
            }
        }

        Object.defineProperty(constructor, Symbol.metadata, {
            value: metadata,
            writable: false,
            configurable: true
        });

        return metadata;
    },

    isValidInstance(klass: any) {
        return (
            klass.constructor[Symbol.metadata] &&
            Object.prototype.hasOwnProperty.call(klass.constructor[Symbol.metadata], $numFields) as boolean
        );
    },

    getFields(klass: any) {
        const metadata: Metadata = klass[Symbol.metadata];
        const fields: any = {};
        for (let i = 0; i <= metadata[$numFields]; i++) {
            fields[metadata[i].name] = metadata[i].type;
        }
        return fields;
    },

    hasViewTagAtIndex(metadata: Metadata, index: number) {
        return metadata?.[$viewFieldIndexes]?.includes(index);
    }
}