import { assert } from "../../../core/assert.js";
import { BinaryDataType } from "../../../core/binary/type/BinaryDataType.js";
import { RowFirstTable } from "../../../core/collection/table/RowFirstTable.js";
import { RowFirstTableSpec } from "../../../core/collection/table/RowFirstTableSpec.js";
import { inverseLerp } from "../../../core/math/inverseLerp.js";
import { lerp } from "../../../core/math/lerp.js";
import { max2 } from "../../../core/math/max2.js";
import { min2 } from "../../../core/math/min2.js";
import { validate_enum_schema } from "../../../core/model/validate_enum_schema.js";
import { table_find_min_index_in_ordered_column } from "./table_find_min_index_in_ordered_column.js";

/**
 *
 * @type {number[]}
 */
const scratch_row = [];

/**
 * @template T
 */
export class TimeSeries {
    /**
     * @type {RowFirstTableSpec}
     */
    spec

    /**
     * @type {RowFirstTable}
     */
    table

    time_column_index = 0;

    /**
     *
     * @type {Object<BinaryDataType>}
     */
    #schema = {}

    /**
     *
     * @type {Object<number>}
     */
    #schema_column_names_to_indices = {};

    /**
     *
     * @type {string[]}
     */
    #schema_column_indices_to_names = [];

    /**
     *
     * @param {Object<BinaryDataType>} schema
     * @param {string} [time_column_name]
     */
    constructor(schema, time_column_name = 'time') {
        assert.isString(time_column_name, 'time_column_name');
        assert.notNull(schema, 'schema');
        assert.isObject(schema, 'schema');

        let issues = [];

        if (!validate_enum_schema(schema, BinaryDataType, (issue) => issues.push(issue))) {
            throw new TypeError(`Invalid schema. Problems:\n ${issues.join('\n')}`);
        }

        this.#schema = schema;

        const schema_keys = Object.keys(schema);

        const time_row_index = schema_keys.indexOf(time_column_name);

        if (time_row_index === -1) {
            throw new TypeError(`supplies schema does not include time column '${time_column_name}', exising columns: ${schema_keys.join(', ')}`);
        }

        this.time_column_index = time_row_index;


        this.#schema_column_names_to_indices = {};

        for (let i = 0; i < schema_keys.length; i++) {
            const key = schema_keys[i];

            this.#schema_column_names_to_indices[key] = i;
            this.#schema_column_indices_to_names[i] = key;
        }

        this.spec = RowFirstTableSpec.get(Object.values(schema));

        this.table = new RowFirstTable(this.spec);

    }

    /**
     *
     * @param {number[]} row
     * @param {Object} object
     */
    #object_to_row(row, object) {

        const columnCount = this.spec.getColumnCount();

        for (let i = 0; i < columnCount; i++) {

            const key = this.#schema_column_indices_to_names[i];

            const value = object[key];

            const t = typeof value;

            if (t !== "number") {
                throw new TypeError(`Expected sample.${key} to be a number, instead was ${t} (=${value})`);
            }

            row[i] = value;

        }
    }

    /**
     *
     * @param {Object} object
     * @param {number[]} row
     */
    #row_to_object(object, row) {

        const columnCount = this.spec.getColumnCount();
        for (let i = 0; i < columnCount; i++) {

            const key = this.#schema_column_indices_to_names[i];

            object[key] = row[i];
        }

    }

    /**
     *
     * @param {number[]} row
     * @returns {Object}
     */
    #row_to_object_allocating(row) {

        const result = {};

        this.#row_to_object(result, row);

        return result;
    }

    /**
     *
     * @param {number} index
     * @returns {Object}
     */
    getSampleObjectByIndex(index) {
        this.table.readRow(index, scratch_row);

        return this.#row_to_object_allocating(scratch_row);
    }

    /**
     *
     * @returns {number}
     */
    get sample_count() {
        return this.table.length;
    }

    /**
     *
     * @returns {number}
     */
    get last_timestamp() {
        const table = this.table;
        const record_count = table.length;

        if (record_count === 0) {
            // default
            return 0;
        }

        const time_column = this.time_column_index;
        return table.readCellValue(record_count - 1, time_column);
    }

    /**
     *
     * @param {number[]} data
     */
    validateNextSample(data) {

        assert.isArrayLike(data, 'data');

        const table = this.table;
        const record_count = table.length;

        if (record_count > 0) {
            const time_column = this.time_column_index;
            const last_time = table.readCellValue(record_count - 1, time_column);

            const record = data[time_column];

            assert.isNumber(record, `data[${time_column}](time)`);
            assert.notNaN(record, `data[${time_column}](time)`);
            assert.isFinite(record, `data[${time_column}](time)`);

            if (record <= last_time) {
                throw new Error(`Sample.time[${time_column}] = ${record}, which is <= to previous time stamp(=${last_time}). Each sample's time stamp must be strictly greater than the previous one`);
            }
        }

    }

    /**
     *
     * @param {number[]} data
     */
    addSample(data) {
        // validate
        this.validateNextSample(data);

        const table = this.table;
        table.addRow(data);
    }

    /**
     *
     * @param {Object} sample
     */
    addObjectSample(sample) {
        this.#object_to_row(scratch_row, sample);

        this.addSample(scratch_row);
    }

    /**
     *
     * @param {number[]} result
     * @param {number} index
     */
    getSampleByIndex(result, index) {
        this.table.readRow(index, result)
    }

    /**
     *
     * @param {number} time
     * @returns {number}
     */
    findLowSampleIndexByTime(time) {
        const table = this.table;
        const time_column_index = this.time_column_index;
        return max2(0, table_find_min_index_in_ordered_column(table, time, time_column_index))
    }

    /**
     *
     * @param {number[]} result
     * @param {number} result_offset
     * @param {number} time
     */
    sampleLinear(result, result_offset, time) {
        const table = this.table;
        const time_column_index = this.time_column_index;

        // seek to the right sample
        const sample_index = this.findLowSampleIndexByTime(time);
        const next_sample_index = min2(table.length - 1, sample_index + 1);

        const prev = table.readCellValue(sample_index, time_column_index);
        const next = table.readCellValue(next_sample_index, time_column_index);

        const normalized_offset = inverseLerp(prev, next, time);

        const column_count = this.spec.getColumnCount();

        for (let i = 0; i < column_count; i++) {
            const v0 = table.readCellValue(sample_index, i);
            const v1 = table.readCellValue(next_sample_index, i);

            result[result_offset + i] = lerp(v0, v1, normalized_offset)
        }
    }

    /**
     * Get linearly interpolated sample for given time in object form, following supplied schema
     * @param {number} time
     * @returns {Object}
     */
    sampleObjectLinear(time) {

        this.sampleLinear(scratch_row, 0, time);

        return this.#row_to_object_allocating(scratch_row);

    }

}