import { assert } from "../../../../../core/assert.js";

const HEADER_TAG_NATIVE_CLASS = 0;
const HEADER_TAG_REF_SCHEMA = 1;
const HEADER_TAG_INLINE_SCHEMA = 2;

const HEADER_TAG_NULL = 4;
const HEADER_TAG_UNDEFINED = 5;
const HEADER_TAG_BOOLEAN_TRUE = 6;
const HEADER_TAG_BOOLEAN_FALSE = 7;
const HEADER_TAG_POSITIVE_INTEGER = 8;
const HEADER_TAG_NEGATIVE_INTEGER = 9;
const HEADER_TAG_FLOAT = 20;
const HEADER_TAG_STRING = 30;


class SchemaRecord {
    address = -1
    /**
     *
     * @type {Object|null}
     */
    schema = null
    /**
     *
     * @type {string|null}
     */
    typeName = null

    /**
     *
     * @type {{name:string}[]}
     */
    fields = [];

    /**
     *
     * @param {Object} schema
     */
    fromSchema(schema) {
        this.schema = schema;

        this.fields.splice(0, this.fields.length);

        for (const schemaKey in schema) {
            this.fields.push({
                name: schemaKey
            });
        }
    }

    buildSchema() {
        const schema = {};

        const fields = this.fields;
        const field_count = fields.length;
        for (let i = 0; i < field_count; i++) {
            const field = fields[i];

            schema[field.name] = {};
        }

        this.schema = schema;
    }
}

/**
 * Generate a schema from a value object
 * @param {Object} object
 * @returns
 */
function autoSchema(object) {
    const schema = {};
    for (const objectKey in object) {
        const field_value = object[objectKey];

        let field_schema;

        if (typeof field_value === "object") {
            field_schema = autoSchema(field_value);
        } else {
            // no schema for primitives
            field_schema = undefined;
        }

        schema[objectKey] = field_schema;
    }

    return schema;
}

class SchemaTable {
    /**
     *
     * @type {Map<number, SchemaRecord>}
     */
    #address_map = new Map();
    /**
     *
     * @type {Map<Object, SchemaRecord>}
     */
    #schema_map = new Map();


    reset() {
        this.#address_map.clear();
        this.#schema_map.clear();
    }

    /**
     *
     * @param {number} address
     * @returns {SchemaRecord|undefined}
     */
    getSchemaAt(address) {
        return this.#address_map.get(address);
    }

    /**
     *
     * @param {Object} schema
     * @returns {SchemaRecord}
     */
    getRecord(schema) {
        return this.#schema_map.get(schema);
    }

    /**
     *
     * @param {Object} schema
     * @returns {number} -1 if not found
     */
    getAddress(schema) {
        const record = this.#schema_map.get(schema);

        if (record === undefined) {
            return -1;
        }

        return record.address;
    }

    /**
     *
     * @param {SchemaRecord} record
     */
    add(record) {
        this.#schema_map.set(record.schema, record);
        this.#address_map.set(record.address, record);
    }
}

export class BinaryObjectSerializationAdapter2 {
    /**
     *
     * @type {BinarySerializationRegistry|null}
     */
    #adapter_registry = null
    /**
     *
     * @type {ModuleRegistry|null}
     */
    #class_registry = null

    #schema_table = new SchemaTable();

    set module_registry(v) {
        this.#class_registry = v;
    }

    get module_registry() {
        return this.#class_registry;
    }

    set adapter_registry(v) {
        this.#adapter_registry = v;
    }

    get adapter_registry() {
        return this.#adapter_registry;
    }

    reset() {
        this.#schema_table.reset();
    }

    /**
     *
     * @param {BinaryBuffer} buffer
     * @param {Object} schema
     * @param {string} typeName
     * @returns {SchemaRecord}
     */
    #writeSchema(buffer, schema, typeName) {
        const address = buffer.position;

        const record = new SchemaRecord();

        record.typeName = typeName;
        record.address = address;

        record.fromSchema(schema);

        // write type name
        buffer.writeUTF8String(typeName);

        const fields = record.fields;
        const field_count = fields.length;

        buffer.writeUintVar(field_count);

        for (let i = 0; i < field_count; i++) {
            // write field
            const field = fields[i];

            buffer.writeUTF8String(field.name);
        }

        this.#schema_table.add(record);

        return record;
    }

    /**
     *
     * @param {BinaryBuffer} buffer
     * @returns {SchemaRecord}
     */
    #readSchema(buffer) {
        const address = buffer.position;

        const record = new SchemaRecord();
        record.address = address;

        // read type name
        const typeName = buffer.readUTF8String();

        record.typeName = typeName;

        const field_count = buffer.readUintVar();

        for (let i = 0; i < field_count; i++) {
            const field_name = buffer.readUTF8String();

            record.fields[i] = {
                name: field_name
            };
        }

        record.buildSchema();

        this.#schema_table.add(record);

        return record;
    }

    /**
     *
     * @param {BinaryBuffer} buffer
     * @param {number|string|boolean|null|undefined|object} object
     * @returns {boolean}
     */
    #trySerializePrimitive(buffer, object) {
        if (object === null) {
            // special case
            buffer.writeUint8(HEADER_TAG_NULL);
            return true;
        }

        const typeOfValue = typeof object;

        if (typeOfValue === "object") {
            return false;
        }


        switch (typeOfValue) {
            case "undefined":
                buffer.writeUint8(HEADER_TAG_UNDEFINED);
                break;
            case "boolean":
                buffer.writeUint8(object ? HEADER_TAG_BOOLEAN_TRUE : HEADER_TAG_BOOLEAN_FALSE);
                break;
            case "number":
                if (Number.isInteger(object)) {
                    if (object >= 0) {
                        // positive integer
                        buffer.writeUint8(HEADER_TAG_POSITIVE_INTEGER);
                        buffer.writeUintVar(object);
                    } else {
                        // negative integer
                        buffer.writeUint8(HEADER_TAG_NEGATIVE_INTEGER);
                        buffer.writeUintVar(-object);
                    }
                } else {
                    // float
                    buffer.writeUint8(HEADER_TAG_FLOAT);
                    buffer.writeFloat64(object);
                }
                break;
            case "string":
                buffer.writeUint8(HEADER_TAG_STRING);
                buffer.writeUTF8String(object);
                break;
            default:
                throw new TypeError(`Unsupported value type '${typeOfValue}'`);
        }

        return true;
    }

    /**
     *
     * @param {BinaryBuffer} buffer
     * @param {Object} object
     * @param {Object} [schema]
     */
    serialize(buffer, object, schema) {
        if (this.#trySerializePrimitive(buffer, object)) {
            // was primitive, serialized
            return;
        }

        const ctor = object.constructor;

        const associatedNames = this.#class_registry.findNamesByModule(ctor);

        let typeName;

        if (associatedNames.length === 1) {
            typeName = associatedNames[0];
        } else {
            // ambiguous, use type name from constructor
            typeName = ctor.typeName
        }

        assert.isString(typeName, 'typeName');

        const nativeAdapter = this.#adapter_registry.getAdapter(typeName);

        const using_native_serializer = nativeAdapter !== undefined;

        if (using_native_serializer) {

            // write header indicating that native serializer is being used
            buffer.writeUint8(HEADER_TAG_NATIVE_CLASS);

            // name of the type
            buffer.writeUTF8String(typeName);

            // serialize with native
            nativeAdapter.serialize(buffer, object);
            return;
        }


        if (schema === undefined) {
            // no schema specified, generate one
            schema = autoSchema(object);
        }


        let record = this.#schema_table.getRecord(schema);

        if (record === undefined) {
            // no record

            // indicating that the full schema follows
            buffer.writeUint8(HEADER_TAG_INLINE_SCHEMA);

            record = this.#writeSchema(buffer, schema, typeName);


        } else {
            buffer.writeUint8(HEADER_TAG_REF_SCHEMA);
            // write address of schema
            buffer.writeUintVar(record.address);
        }


        // write data according to schema

        const fields = record.fields;
        const field_count = fields.length;

        for (let i = 0; i < field_count; i++) {
            const field = fields[i];

            const field_name = field.name;

            const actual_value = object[field_name];

            this.serialize(buffer, actual_value, schema[field_name]);
        }

    }

    /**
     *
     * @template T
     * @param {BinaryBuffer} buffer
     * @param {number} tag
     * @returns {T}
     */
    #deserializeObject(buffer, tag) {
        let record;

        if (tag === HEADER_TAG_INLINE_SCHEMA) {
            // read full schema here
            record = this.#readSchema(buffer);

        } else if (tag === HEADER_TAG_REF_SCHEMA) {
            // reference to the schema else-where
            const schema_address = buffer.readUintVar();

            record = this.#schema_table.getRecord(schema_address);
        }

        const typeName = record.typeName;

        const Klass = this.#class_registry.get(typeName);

        const object = new Klass();

        const fields = record.fields;
        const field_count = fields.length;

        for (let i = 0; i < field_count; i++) {
            const field = fields[i];

            object[field.name] = this.deserialize(buffer);
        }

        return object;
    }

    /**
     * @template T
     * @param {BinaryBuffer} buffer
     * @returns {T}
     */
    deserialize(buffer) {

        // read header
        const header = buffer.readUint8();

        switch (header) {
            case HEADER_TAG_NATIVE_CLASS:
                // native serializer being used
                const typeName = buffer.readUTF8String();

                const adapter = this.#adapter_registry.getAdapter(typeName);

                const Klass = this.#class_registry.get(typeName);

                const object = new Klass();

                adapter.deserialize(buffer, object);

                return object;
            case HEADER_TAG_INLINE_SCHEMA:
            case HEADER_TAG_REF_SCHEMA:
                return this.#deserializeObject(buffer, header);

            case HEADER_TAG_NULL:
                return null;
            case HEADER_TAG_UNDEFINED:
                return undefined;
            case HEADER_TAG_BOOLEAN_TRUE:
                return true;
            case HEADER_TAG_BOOLEAN_FALSE:
                return false;
            case HEADER_TAG_POSITIVE_INTEGER:
                return buffer.readUintVar();
            case HEADER_TAG_NEGATIVE_INTEGER:
                return -buffer.readUintVar();
            case HEADER_TAG_FLOAT:
                return buffer.readFloat64();
            case HEADER_TAG_STRING:
                return buffer.readUTF8String();
            default:
                throw new Error(`Unsupported tag '${header}'`);
        }


    }
}

/**
 * @readonly
 * @type {number}
 */
BinaryObjectSerializationAdapter2.prototype.version = 0;
