base_Model.js

/**
 * Model - A flexible data model with schema validation, computed properties, and event system
 * 
 * Features:
 * - Schema-based validation with type coercion and defaults
 * - Nested property access using dotted paths (e.g., 'user.profile.name')
 * - Computed properties that update automatically
 * - Event system for change notifications
 * - Deep merging and batch operations
 * - JSON serialization support
 * 
 * @example
 * // Basic usage
 * const user = new Model({ name: 'John', age: 30 });
 * user.name = 'Jane';
 * console.log(user.getValues()); // { name: 'Jane', age: 30 }
 * 
 * @example
 * // With schema validation
 * class UserModel extends Model {
 *   static schema = {
 *     name: { type: 'string', required: true },
 *     age: { type: 'number', default: 18 },
 *     email: { type: 'string', transform: v => v?.toLowerCase() },
 *     status: { type: 'string', enum: ['active', 'inactive'], default: 'active' }
 *   };
 * }
 * 
 * const user = new UserModel({ name: 'John', email: 'JOHN@TEST.COM' });
 * console.log(user.email); // 'john@test.com' (transformed)
 * console.log(user.age);   // 18 (default value)
 * 
 * @example
 * // Nested properties with dotted paths
 * class ProfileModel extends Model {
 *   static schema = {
 *     'user.name': { type: 'string', required: true },
 *     'user.email': { type: 'string', default: 'noemail@test.com' },
 *     'settings.theme': { type: 'string', default: 'light' }
 *   };
 * }
 * 
 * const profile = new ProfileModel({ 'user.name': 'Alice' });
 * console.log(profile.get('user.name'));      // 'Alice'
 * console.log(profile.get('settings.theme')); // 'light'
 * 
 * @example
 * // Computed properties
 * class PersonModel extends Model {
 *   static computed = {
 *     fullName: (model) => `${model.firstName || ''} ${model.lastName || ''}`.trim(),
 *     isAdult: (model) => (model.age || 0) >= 18
 *   };
 * }
 * 
 * const person = new PersonModel({ firstName: 'John', lastName: 'Doe', age: 25 });
 * console.log(person.fullName); // 'John Doe'
 * console.log(person.isAdult);  // true
 * 
 * @example
 * // Event handling
 * const model = new Model({ count: 0 });
 * model.on('change:count', ({ from, to }) => {
 *   console.log(`Count changed from ${from} to ${to}`);
 * });
 * model.count = 5; // Triggers event
 * 
 * @example
 * // Batch operations and merging
 * const model = new Model({ user: { name: 'John', age: 30 } });
 * 
 * // Deep merge - preserves existing nested properties
 * model.mergeValues({ user: { email: 'john@test.com' } });
 * console.log(model.get('user.name')); // 'John' (preserved)
 * console.log(model.get('user.email')); // 'john@test.com' (added)
 * 
 * // Batch replace
 * model.setValues({ user: { name: 'Jane' } }); // Replaces entire user object
 * console.log(model.get('user.age')); // undefined (lost)
 * 
 * @example
 * // Factory method
 * const model = Model.fromJSON({ name: 'John', nested: { value: 42 } });
 * const json = model.toJSON(); // Deep cloned plain object
 */
class Model {
    constructor(props = {}) {
        if (!Model.isPlainObject(props)) {
            throw new Error("Model expects a plain object.");
        }

        this._props = {};
        this._events = Object.create(null);

        // Precompute computed names for conflict checks
        const computed = this.constructor.computed || {};
        this._computedNames = new Set(Object.keys(computed));

        // Apply schema defaults first (top-level & dotted paths)
        const schema = this.constructor.schema || {};
        for (const [k, rule] of Object.entries(schema)) {
            if (!rule || !Object.prototype.hasOwnProperty.call(rule, "default")) continue;
            // Only apply default if not provided in props (supports dotted or top-level)
            const hasProvided = k.includes(".")
                ? Model.#hasPath(props, k)
                : Object.prototype.hasOwnProperty.call(props, k);
            if (!hasProvided) {
                const defVal = (typeof rule.default === "function")
                    ? rule.default()
                    : Model.#deepClone(rule.default);
                if (k.includes(".")) {
                    Model.#setByPath(this._props, k, defVal);
                } else {
                    this._props[k] = defVal;
                }
            }
        }

        // Ingest initial props with validation & coercion
        this.setValues(props, { addNew: true, silent: true, validate: true, coerce: true });

        // Expose initial top-level accessors
        for (let field of Object.keys(this._props)) {
            this.#definePropAccessor(field);
        }

        // Add computed fields
        for (let [field, fn] of Object.entries(computed)) {
            if (Object.prototype.hasOwnProperty.call(this, field) || Object.prototype.hasOwnProperty.call(this._props, field)) {
                throw new Error(`Computed property "${field}" conflicts with a data property.`);
            }
            Object.defineProperty(this, field, {
                get: () => fn(this),
                enumerable: true
            });
        }

        // Enforce required (both top-level & dotted)
        for (const [k, rule] of Object.entries(schema)) {
            if (rule && rule.required) {
                const val = k.includes(".")
                    ? Model.#getByPath(this._props, k, undefined)
                    : this._props[k];
                if (typeof val === "undefined") {
                    throw new Error(`Missing required property "${k}".`);
                }
            }
        }
    }

    // ===== Helpers =====
    static isPlainObject(obj) {
        return (
            typeof obj === "object" &&
            obj !== null &&
            Object.getPrototypeOf(obj) === Object.prototype
        );
    }

    static #deepClone(value) {
        if (typeof structuredClone === "function") return structuredClone(value);
        if (Array.isArray(value)) return value.map(v => Model.#deepClone(v));
        if (Model.isPlainObject(value)) {
            const out = {};
            for (const k in value) out[k] = Model.#deepClone(value[k]);
            return out;
        }
        return value;
    }

    #definePropAccessor(field) {
        if (this._computedNames.has(field)) {
            throw new Error(`Cannot expose "${field}" as data accessor: name is used by computed property.`);
        }
        if (Object.prototype.hasOwnProperty.call(this, field)) return;
        Object.defineProperty(this, field, {
            get: function () {
                return this._props[field];
            },
            set: function (newValue) {
                const processed = this.#applySchemaOnSet(field, newValue, { validate: true, coerce: true });
                const prev = this._props[field];
                if (!Object.is(prev, processed)) {
                    this._props[field] = processed;
                    this.emit(`change:${field}`, { key: field, from: prev, to: processed });
                    this.emit("change", { [field]: { from: prev, to: processed } });
                }
            },
            enumerable: true,
        });
    }

    static #setByPath(target, path, value) {
        const parts = path.split(".");
        let obj = target;
        for (let i = 0; i < parts.length - 1; i++) {
            const k = parts[i];
            if (!Model.isPlainObject(obj[k])) obj[k] = {};
            obj = obj[k];
        }
        const last = parts[parts.length - 1];
        const prev = obj[last];
        obj[last] = value;
        return prev;
    }

    static #getByPath(target, path, defValue) {
        return path.split(".").reduce((prev, curr) => {
            return prev && Object.prototype.hasOwnProperty.call(prev, curr) ? prev[curr] : defValue;
        }, target);
    }

    static #hasPath(target, path) {
        const parts = path.split(".");
        let obj = target;
        for (let i = 0; i < parts.length; i++) {
            const k = parts[i];
            if (!obj || !Object.prototype.hasOwnProperty.call(obj, k)) return false;
            obj = obj[k];
        }
        return true;
    }

    static #deleteByPath(target, path) {
        const parts = path.split(".");
        let obj = target;
        for (let i = 0; i < parts.length - 1; i++) {
            const k = parts[i];
            if (!Model.isPlainObject(obj[k])) return undefined;
            obj = obj[k];
        }
        const last = parts[parts.length - 1];
        const prev = obj[last];
        if (Object.prototype.hasOwnProperty.call(obj, last)) {
            delete obj[last];
            return prev;
        }
        return undefined;
    }

    // ===== Schema / Validation (supports dotted paths) =====
    // schema example:
    // static schema = {
    //   "firstname": { type: "string", required: true, transform: v => v?.trim() },
    //   "address.city": { type: "string", required: true },
    //   "tags": { type: "array", default: () => [] }
    // }
    #applySchemaOnSet(keyOrPath, value, { validate = true, coerce = true } = {}) {
        const schema = this.constructor.schema || {};
        const rule = schema[keyOrPath] ?? (keyOrPath.includes(".") ? undefined : schema[keyOrPath]);
        if (!rule) return value;

        let v = value;

        // type handling / coercion
        if (rule.type && v !== undefined && v !== null) {
            v = this.#coerceType(v, rule.type, coerce);
        }

        // enum
        if (validate && rule.enum && !rule.enum.includes(v)) {
            throw new Error(`Invalid value for "${keyOrPath}". Expected one of [${rule.enum.join(", ")}].`);
        }

        // transform
        if (rule.transform) {
            v = rule.transform(v);
        }

        // custom validate
        if (validate && rule.validate) {
            const ok = rule.validate(v, { key: keyOrPath, model: this });
            if (ok !== true) {
                const msg = typeof ok === "string" ? ok : `Validation failed for "${keyOrPath}".`;
                throw new Error(msg);
            }
        }

        return v;
    }

    #coerceType(value, type, coerce) {
        const t = Array.isArray(type) ? type : [type];
        for (const candidate of t) {
            if (candidate === "string") {
                if (typeof value === "string") return value;
                if (coerce && value != null) return String(value);
            } else if (candidate === "number") {
                if (typeof value === "number" && Number.isFinite(value)) return value;
                if (coerce) {
                    const n = Number(value);
                    if (Number.isFinite(n)) return n;
                }
            } else if (candidate === "boolean") {
                if (typeof value === "boolean") return value;
                if (coerce) {
                    if (value === "true" || value === "1" || value === 1) return true;
                    if (value === "false" || value === "0" || value === 0) return false;
                }
            } else if (candidate === "object") {
                if (Model.isPlainObject(value)) return value;
            } else if (candidate === "array") {
                if (Array.isArray(value)) return value;
                if (coerce && value != null) return [value];
            } else if (candidate === "date") {
                if (value instanceof Date && !isNaN(value.getTime())) return value;
                if (coerce) {
                    const d = new Date(value);
                    if (!isNaN(d.getTime())) return d;
                }
            }
        }
        return value;
    }

    // ===== Events =====
    on(event, handler) {
        if (!this._events[event]) this._events[event] = new Set();
        this._events[event].add(handler);
        return this;
    }
    off(event, handler) {
        if (!event) { this._events = Object.create(null); return this; }
        const set = this._events[event];
        if (!set) return this;
        if (handler) set.delete(handler);
        else delete this._events[event];
        return this;
    }
    once(event, handler) {
        const wrap = (payload) => { this.off(event, wrap); handler(payload); };
        return this.on(event, wrap);
    }
    emit(event, payload) {
        const set = this._events[event];
        if (set) for (const fn of Array.from(set)) { try { fn(payload); } catch(_){} }
        return this;
    }

    // ===== API =====
    get(key, defValue = null) {
        if (key.includes(".")) {
            return Model.#getByPath(this._props, key, defValue);
        } else if (Object.prototype.hasOwnProperty.call(this._props, key)) {
            return this._props[key];
        } else {
            return defValue;
        }
    }

    getValuesExcept(excludedKeys = []) {
        if (Array.isArray(excludedKeys) && excludedKeys.length > 0) {
            const vals = {};
            for (let k in this._props) {
                if (!excludedKeys.includes(k)) {
                    vals[k] = this._props[k];
                }
            }
            return vals;
        }
        return this._props;
    }

    getValues() {
        return this._props;
    }

    toJSON() {
        return Model.#deepClone(this._props);
    }

    /**
     * Batch update values (overwrites object nodes).
     */
    setValues(values = {}, { addNew = true, silent = true, removeMissing = false, validate = true, coerce = true } = {}) {
        if (!Model.isPlainObject(values)) {
            throw new Error("setValues expects a plain object.");
        }

        const changes = {};
        const schema = this.constructor.schema || {};

        // Add/update provided values
        for (const [key, rawNext] of Object.entries(values)) {
            if (!key.includes(".") && this._computedNames.has(key)) {
                throw new Error(`Cannot set "${key}": it is a computed property.`);
            }

            if (!key.includes(".") && addNew && !(key in this)) {
                this.#definePropAccessor(key);
            }

            const next = this.#applySchemaOnSet(key, rawNext, { validate, coerce });
            const prev = key.includes(".")
                ? Model.#setByPath(this._props, key, next)
                : (() => { const p = this._props[key]; this._props[key] = next; return p; })();

            if (!Object.is(prev, next)) {
                changes[key] = { from: prev, to: next };
                if (!silent) {
                    if (key.includes(".")) this.emit(`changePath:${key}`, { path: key, from: prev, to: next });
                    else this.emit(`change:${key}`, { key, from: prev, to: next });
                }
            }
        }

        // Remove missing top-level keys if requested
        if (removeMissing) {
            const keep = new Set(Object.keys(values).filter(k => !k.includes(".")));
            for (const k of Object.keys(this._props)) {
                if (!keep.has(k)) {
                    const rule = schema[k];
                    if (rule && rule.required) continue;
                    const prev = this._props[k];
                    if (typeof prev !== "undefined") {
                        delete this._props[k];
                        changes[k] = { from: prev, to: undefined };
                        if (!silent) this.emit(`remove:${k}`, { key: k, from: prev, to: undefined });
                    }
                }
            }
        }

        if (!silent && Object.keys(changes).length) {
            this.emit("change", changes);
            if (removeMissing) this.emit("remove", changes);
        }

        return changes;
    }

    /**
     * Deep-merge values into model (object nodes are merged, not replaced).
     * Arrays: configurable strategy: 'replace' (default) | 'concat' | 'byIndex'
     */
    mergeValues(values = {}, {
        addNew = true,
        silent = true,
        validate = true,
        coerce = true,
        arrayStrategy = "replace" // 'replace' | 'concat' | 'byIndex'
    } = {}) {
        if (!Model.isPlainObject(values)) {
            throw new Error("mergeValues expects a plain object.");
        }

        const changes = {};
        const walk = (base, patch, pathPrefix = "") => {
            for (const [k, rawNext] of Object.entries(patch)) {
                const path = pathPrefix ? `${pathPrefix}.${k}` : k;

                // Computed guard for top-level keys
                if (!path.includes(".") && this._computedNames.has(path)) {
                    throw new Error(`Cannot set "${path}": it is a computed property.`);
                }

                // Ensure accessor for top-level new keys
                if (!path.includes(".") && addNew && !(k in this)) {
                    this.#definePropAccessor(k);
                }

                const prev = Model.#getByPath(this._props, path, undefined);

                // Recurse for object merges
                if (Model.isPlainObject(rawNext) && Model.isPlainObject(prev)) {
                    // Merge object recursively
                    walk(base, rawNext, path);
                    continue;
                }

                // Array handling
                let next = rawNext;
                if (Array.isArray(rawNext) && Array.isArray(prev)) {
                    if (arrayStrategy === "concat") next = prev.concat(rawNext);
                    else if (arrayStrategy === "byIndex") {
                        next = prev.slice();
                        for (let i = 0; i < rawNext.length; i++) next[i] = rawNext[i];
                    } // else 'replace' -> keep rawNext
                }

                // Apply schema (per-path supported)
                next = this.#applySchemaOnSet(path, next, { validate, coerce });

                // Write and diff
                const old = Model.#setByPath(this._props, path, next);
                if (!Object.is(old, next)) {
                    changes[path] = { from: old, to: next };
                    if (!silent) {
                        if (path.includes(".")) this.emit(`changePath:${path}`, { path, from: old, to: next });
                        else this.emit(`change:${path}`, { key: path, from: old, to: next });
                    }
                }
            }
        };

        walk(this._props, values, "");

        if (!silent && Object.keys(changes).length) {
            this.emit("change", changes);
        }

        return changes;
    }

    // ===== Factory =====
    static fromJSON(json) {
        if (!Model.isPlainObject(json)) {
            throw new Error("fromJSON expects a plain object.");
        }
        // Ensure we don’t retain references to caller's object
        return new this(Model.#deepClone(json));
    }
}

export { Model };