const {checkType, checkInstance, createHiddenProp} = require("../index").Utility;

function checkBounds(max, val) {
    // TODO: check to make sure val is a number
    if (val >= max) {
        throw new RangeError(`Attempting to access value out of bounds: requested ${val}, max is ${max - 1}`);
    }

    if (val < 0) {
        throw new RangeError(`Attempting to access value out of bounds: requested ${val}`);
    }
}

function getProp(name) {
    if (name[0] === "_") {
        throw new Error(`attempting to access private property ${name}`);
    }

    let n = Number.parseInt(name);
    if (Number.isInteger(n)) {
        return n;
    }

    return name;
}

class yValue {
    constructor(dataBuf, height, x, opts = {}) {
        createHiddenProp(this, "_height", height, true);
        createHiddenProp(this, "_baseOffset", height * x, true);
        createHiddenProp(this, "_dataBuf", dataBuf, true);
        this.serializer = opts.serializer || defaultSerializer;
        this.converter = opts.converter || defaultConverter;

        return new Proxy(this, {
            get: this.getHandler.bind(this),
            set: this.setHandler.bind(this),
        });
    }

    // eslint-disable-next-line no-unused-vars
    getHandler(target, idx, receiver) {
        idx = getProp(idx);

        if (Number.isInteger(idx)) {
            checkBounds(this._height, idx);
            let offset = this._baseOffset + idx;
            return this._dataBuf[offset];
        }

        throw new Error(`can't get property ${idx}`);
    }

    // eslint-disable-next-line no-unused-vars
    setHandler(target, idx, value, receiver) {
        idx = getProp(idx);

        if (Number.isInteger(idx)) {
            checkBounds(this._height, idx);
            let offset = this._baseOffset + idx;
            this._dataBuf[offset] = this.converter(value);
            return true;
        }

        throw new Error(`can't set property ${idx}`);
    }
}

/**
 * A two-dimensional array class
 */
class Grid {
    /**
     * Creates a new two dimensional array
     *
     * @param {number}   width           - The width of the Grid
     * @param {height}   height          - The height of the Grid
     * @param {object}   opts            - Options
     * @param {Function} opts.serializer - A function that converts values from a number to a string for `toString`. Function accepts a single 'num' argument that is the Number to be converted and returns a string.
     * @param {Function} opts.converter  - A function that converts values to be stored in the Grid. Function accepts a single 'val' argument and returns an number.
     */
    constructor(width, height, opts = {}) {
        createHiddenProp(this, "_width", width, true);
        createHiddenProp(this, "_height", height, true);

        let db;
        if (opts.buffer && (opts.buffer instanceof Uint8Array)) {
            if (opts.buffer.byteLength !== (width * height)) {
                throw new Error("Grid constructor 'opts.buffer' was wrong size, didn't match width * height");
            }

            db = Uint8Array.from(opts.buffer);
        } else {
            db = new Uint8Array(width * height);
        }

        createHiddenProp(this, "_dataBuf", db, true);

        let xArray = [];
        for (let x = 0; x < width; x++) {
            xArray[x] = new yValue(this._dataBuf, height, x, opts);
        }

        createHiddenProp(this, "_xArray", xArray, true);
        this.serializer = opts.serializer || defaultSerializer;
        this.converter = opts.converter || defaultConverter;

        return new Proxy(this, {
            get: getHandler.bind(this),
            set: setHandler.bind(this),
        });
    }

    /** How wide the Grid is */
    get width() {
        return this._width;
    }

    /** How tall the Grid is */
    get height() {
        return this._height;
    }

    /** A {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array|Uint8Array} for the raw data underlying the Grid */
    get dataBuf() {
        return this._dataBuf;
    }

    // eslint-disable-next-line jsdoc/require-jsdoc
    get [Symbol.toStringTag]() {
        return "Grid";
    }

    /**
     * Convert the Grid to a human-readable string. If `converter` was specified as an option during construction, it is used to
     * convert each element.
     *
     * @returns {string} A human-readable string representing the Grid.
     */
    toString() {
        let output = "";

        for (let y = 0; y < this.height; y++) {
            // output += "\"";
            for (let x = 0; x < this.width; x++) {
                output += this.serializer(this[x][y]);
            }

            // output += "\"\\n +\n";
            output += "\n";
        }

        return output;
    }

    /**
     * Creates a copy of the Grid with the same values but different underlying data.
     *
     * @returns {Grid} A duplicate of the Grid
     */
    copy() {
        let ret = new Grid(this.width, this.height, {
            serializer: this.serializer,
            converter: this.converter,
            buffer: this.dataBuf,
        });

        return ret;
    }

    /**
     * Resets all data in the Grid to zero.
     */
    clear() {
        this.dataBuf.fill(0, 0, this.dataBuf.buffer.length);
    }

    /**
     * Iterates all the cells of the Grid
     *
     * @param   {Function} cb Called for each cell of the Grid. Signature is `cb(value, x, y, grid)`.
     */
    forEach(cb) {
        checkType("forEach", "cb", cb, "function");
        for (let y = 0; y < this.height; y++) {
            for (let x = 0; x < this.width; x++) {
                cb(this[x][y], x, y, this);
            }
        }
    }

    /**
     * Creates a new Grid from an Array of strings.
     *
     * @param   {Array.<string>} val An array of equal length strings to set the initial value of the Grid.
     * The Grid height will be equal to the number of elements in the array, and the width will be equal to
     * the length of the strings.
     */
    static from(val) {
        if (Array.isArray(val)) {
            checkType("Grid.from", "val[0]", val[0], "string");
            // TODO: assuming array of strings
            // TODO: all lines are the same length
            let w = val[0].length;
            let h = val.length;
            let g = new Grid(w, h);

            g.forEach((v, x, y) => {
                g[x][y] = val[y][x];
            });

            return g;
        }

        // TODO: .from(ArrayBuffer,x,y)
        throw new TypeError(`Grid.from got unexpected type: ${val}`);
    }

    /**
     * Compares two Grids and returns an Array of the differences. Grids must be the same height and width.
     *
     * @param {Grid} src - The first grid to compare.
     * @param {Grid} dst - The second grid to compare.
     *
     * @returns {Array.<object>|null}     An Array of Objects describing the differences. Object for each change is `{x, y, srcVal, dstVal}`. Returns `null` if there are no differences.
     */
    static diff(src, dst) {
        checkInstance("diff", "src", src, Grid);
        checkInstance("diff", "dst", dst, Grid);
        if (src.height !== dst.height || src.width !== dst.width) {
            throw new Error(`diff expected Grids to be same size: src(${src.width},${src.height}) vs dst(${dst.width},${dst.height})`);
        }

        let ret = [];
        src.forEach((v, x, y) => {
            if (src[x][y] !== dst[x][y]) {
                ret.push({
                    x,
                    y,
                    srcVal: src[x][y],
                    dstVal: dst[x][y],
                });
            }
        });

        return ret.length ? ret : null;
    }
}

// eslint-disable-next-line no-unused-vars
function getHandler(target, idx, receiver) {
    idx = getProp(idx);

    if (Number.isInteger(idx)) {
        checkBounds(this._width, idx);
        return this._xArray[idx];
    }

    return this[idx];
}

// eslint-disable-next-line no-unused-vars
function setHandler(target, idx, value, receiver) {
    // console.log("Grid setHandler");
    // idx = getProp(idx);
    // if (Number.isInteger(idx)) throw new Error(`can't set 'x' value: ${idx}`);
    throw new Error(`can't set property ${idx}`);
}

function defaultSerializer(val) {
    if (val === undefined) {
        return "    ";
    }

    return val.toString().padStart(4);
}

function defaultConverter(val) {
    if (typeof val === "number") {
        return val;
    }

    if (typeof val === "string" && val.length === 1) {
        return (val === " ") ? 0 : val.charCodeAt(0);
    }

    throw new Error(`unable to convert value ${val} to number`);
}

module.exports = {Grid};