import {deepClone, getDefiningClass, getProperty, isEmpty, isPlainObject, iterateEntries, iterateKeys, mergeObject} from "../utils/helpers.mjs";
import {logCompatibilityWarning} from "../utils/logging.mjs";
import {DataModelSchemaField, EmbeddedDataField, EmbeddedCollectionField} from "../data/fields.mjs";
import * as operators from "../data/operators.mjs";
import {DataModelValidationFailure} from "../data/validation-failure.mjs";

/**
 * @import {DataModelUpdateState, DataModelCleaningOptions} from "../data/_types.mjs";
 * @import {DataField} from "../data/fields.mjs";
 * @import {
 *  DataModelConstructionContext,
 *  DataModelFromSourceOptions,
 *  DataModelSanitizationOptions,
 *  DataModelUpdateOptions,
 *  DataModelValidationOptions,
 *  DataSchema
 * } from "./_types.mjs";
 */

/**
 * An abstract class which is a fundamental building block of numerous structures and concepts in Foundry Virtual
 * Tabletop. Data models perform several essential roles:
 *
 * * A static schema definition that all instances of that model adhere to.
 * * A workflow for data migration, cleaning, validation, and initialization such that provided input data is structured
 *   according to the rules of the model's declared schema.
 * * A workflow for transacting differential updates to the instance data and serializing its data into format suitable
 *   for storage or transport.
 *
 * DataModel subclasses can be used for a wide variety of purposes ranging from simple game settings to high complexity
 * objects like `Scene` documents. Data models are often nested; see the {@link DataModel.parent} property for more.
 *
 * @abstract
 * @template {object} [ModelData=object]
 * @template {DataModelConstructionContext} [ModelContext=DataModelConstructionContext]
 */
export default class DataModel {
  /**
   * @param {Partial<ModelData>} [data={}] Initial data used to construct the data object. The provided object will be
   *                                       owned by the constructed model instance and may be mutated.
   * @param {ModelContext} [options={}]    Context and data validation options which affects initial model construction.
   */
  constructor(data={}, {parent=null, schema, strict=true, ...options}={}) {

    // Parent model
    Object.defineProperty(this, "parent", {
      value: (() => {
        if ( parent === null ) return null;
        if ( parent instanceof DataModel ) return parent;
        throw new Error("The provided parent must be a DataModel instance");
      })(),
      writable: false,
      enumerable: false
    });

    // Explicit instance-level schema
    if ( schema ) {
      if ( !(schema instanceof DataModelSchemaField) ) {
        throw new Error(`The schema provided to the ${this.name}#constructor context is not a DataModelSchemaField
        instance.`);
      }
      Object.defineProperty(this, "schema", {value: schema, writable: false, enumerable: false});
    }

    // Source data
    Object.defineProperty(this, "_source", {
      value: this._initializeSource(data, {strict, ...options}),
      writable: false,
      enumerable: false
    });
    Object.seal(this._source);

    // Additional subclass configurations
    this._configure(options);

    // Data validation and initialization
    this.validate({
      strict,
      fallback: options.fallback ?? !strict,
      dropInvalidEmbedded: options.dropInvalidEmbedded ?? !strict
    });
    this._initialize({strict, ...options});
  }

  /**
   * Configure the data model instance before validation and initialization workflows are performed.
   * @param {object} [options] Additional options modifying the configuration
   * @protected
   */
  _configure(options={}) {}

  /* -------------------------------------------- */

  /**
   * The source data object for this DataModel instance.
   * Once constructed, the source object is sealed such that no keys may be added nor removed.
   * @type {ModelData}
   * @public
   */
  _source;

  /**
   * The defined and cached Data Schema for all instances of this DataModel.
   * @type {DataModelSchemaField}
   * @internal
   */
  static _schema;

  /**
   * An immutable reverse-reference to a parent DataModel to which this model belongs.
   * @type {DataModel|null}
   */
  parent;

  /* ---------------------------------------- */
  /*  Data Schema                             */
  /* ---------------------------------------- */

  /**
   * Define the data schema for models of this type.
   * The schema is populated the first time it is accessed and cached for future reuse.
   *
   * The schema, through its fields, provide the essential cleaning, validation, and initialization methods to turn the
   * {@link _source} values into direct properties of the data model. The schema is a static property of the model and
   * is reused by all instances to perform validation.
   *
   * The schemas defined by the core software in classes like {@link foundry.documents.BaseActor} are validated by the
   * server, where user code does not run. However, almost all documents have a `flags` field to store data, and many
   * have a `system` field that can be configured to be a {@link foundry.abstract.TypeDataModel} instance. Those models
   * are *not* constructed on the server and rely purely on client-side code, which means certain extra-sensitive fields
   * must be also be registered through your package manifest. {@link foundry.packages.types.ServerSanitizationFields}
   *
   * @returns {DataSchema}
   * @abstract
   *
   * @example
   * ```js
   * class SomeModel extends foundry.abstract.DataModel {
   *   static defineSchema() {
   *     return {
   *       foo: new foundry.data.fields.StringField()
   *     }
   *   }
   * }
   *
   * class AnotherModel extends SomeModel {
   *   static defineSchema() {
   *     // Inheritance and object oriented principles apply to schema definition
   *     const schema = super.defineSchema()
   *
   *     schema.bar = new foundry.data.fields.NumberField()
   *
   *     return schema;
   *   }
   * }
   * ```
   */
  static defineSchema() {
    throw new Error(`The ${this.name} subclass of DataModel must define its Document schema`);
  }

  /* ---------------------------------------- */

  /**
   * The Data Schema for all instances of this DataModel.
   * @type {DataModelSchemaField}
   */
  static get schema() {
    if ( this.hasOwnProperty("_schema") ) return this._schema;
    Object.defineProperty(this, "_schema", {
      value: new DataModelSchemaField(this),
      writable: false, enumerable: false, configurable: true});
    return this.schema;
  }

  /* ---------------------------------------- */

  /**
   * Define the data schema for this document instance.
   * @type {DataModelSchemaField}
   */
  get schema() {
    return this.constructor.schema;
  }

  /* ---------------------------------------- */

  /**
   * Is the current state of this DataModel invalid?
   * The model is invalid if there is any unresolved failure.
   * @type {boolean}
   */
  get invalid() {
    return Object.values(this.#validationFailures).some(f => f?.unresolved);
  }

  /**
   * An array of validation failure instances which may have occurred when this instance was last validated.
   * @type {{fields: DataModelValidationFailure|null, joint: DataModelValidationFailure|null}}
   */
  get validationFailures() {
    return this.#validationFailures;
  }

  #validationFailures = Object.seal({fields: null, joint: null });

  /**
   * A set of localization prefix paths which are used by this DataModel. This provides an alternative to defining the
   * `label` and `hint` property of each field by having foundry map the labels to a structure inside the path
   * provided by the prefix.
   *
   * @type {string[]}
   *
   * @example
   * JavaScript class definition and localization call.
   * ```js
   * class MyDataModel extends foundry.abstract.DataModel {
   *   static defineSchema() {
   *     return {
   *       foo: new foundry.data.fields.StringField(),
   *       bar: new foundry.data.fields.NumberField()
   *     };
   *   }
   *   static LOCALIZATION_PREFIXES = ["MYMODULE.MYDATAMODEL"];
   * }
   *
   * Hooks.on("i18nInit", () => {
   *   // Foundry will attempt to automatically localize models registered for a document subtype, so this step is only
   *   // needed for other data model usage, e.g. for a Setting.
   *   Localization.localizeDataModel(MyDataModel);
   * });
   * ```
   *
   * JSON localization file
   * ```json
   * {
   *   "MYMODULE": {
   *     "MYDATAMODEL": {
   *       "FIELDS" : {
   *         "foo": {
   *           "label": "Foo",
   *           "hint": "Instructions for foo"
   *         },
   *         "bar": {
   *           "label": "Bar",
   *           "hint": "Instructions for bar"
   *         }
   *       }
   *     }
   *   }
   * }
   * ```
   */
  static LOCALIZATION_PREFIXES = [];

  /* ---------------------------------------- */

  /**
   * Traverse the data model instance, obtaining the DataField definition for a field of a particular property.
   * @param {string[]|string} key      A property key like ["abilities", "strength"] or "abilities.strength"
   * @returns {DataField|undefined}    The corresponding DataField definition for that field, or undefined
   */
  getFieldForProperty(key) {
    return this.schema.getField(key, {source: this._source});
  }

  /* ---------------------------------------- */
  /*  Data Cleaning Methods                   */
  /* ---------------------------------------- */

  /**
   * Initialize the source data for a new DataModel instance.
   * One-time migrations and initial cleaning operations are applied to the source data.
   * @param {object|DataModel} data   The candidate source data from which the model will be constructed
   * @param {ModelContext} [options]  Options provided to the model constructor
   * @returns {object}                Migrated and cleaned source data which will be stored to the model instance,
   *                                  which is the same object as the `data` argument
   * @protected
   */
  _initializeSource(data, options={}) {
    if ( options.clean !== false ) {
      const cleanOptions = typeof options.clean === "object" ? options.clean : {};
      data = this.constructor.cleanData(data, {
        prune: true,        // Prune changes unless prevented
        migrate: true,      // Migrate data unless prevented
        model: true,        // Joint model cleaning unless prevented
        sanitize: false,    // Data at rest is already sanitized
        ...cleanOptions,    // User-provided cleaning options
        copy: false,        // Data provided for DataModel construction becomes owned by that model
        partial: false      // Source data must not be partial
      }, {creation: options.creation, model: this, source: data});
    }
    return this.constructor.shimData(data);
  }

  /* ---------------------------------------- */

  /**
   * Clean a data source object to conform to a specific provided schema.
   * @param {object} [data]                       Provided model data that requires cleaning
   * @param {DataModelCleaningOptions} [options]  Options that configure how data cleaning is performed
   * @param {Partial<DataModelUpdateState>} [_state]  Internal options used during cleaning recursion
   * @returns {object}                            Cleaned data which is suitable for validation and usage
   */
  static cleanData(data={}, {addTypes=false, copy=true, fields=true, expand=true, migrate=true, model=true,
    partial=false, prune=true, persisted=true, sanitize=true, source: _optionsSource}={}, _state={}) {

    // Re-pack options
    if ( _optionsSource ) {
      logCompatibilityWarning(`You have passed options.source to ${this.name}.cleanData. This source data should now
      be passed as part of the third _state parameter`, {since: 14, until: 16});
      _state.source ||= _optionsSource;
    }
    sanitize = typeof sanitize === "object" ? sanitize : (sanitize ? {} : false); // {...} or false
    const cleanOptions = {addTypes, expand, migrate, model, partial, prune, persisted, sanitize};
    _state.cleanOptions = true;

    // Accept a DataModel instance or a plain object
    if ( data instanceof DataModel ) data = copy ? data.toObject() : data._source;
    else {
      if ( !isPlainObject(data) ) throw new Error(`${this.name} must be constructed with a DataModel or Object.`);
      if ( copy ) data = deepClone(data);
    }

    // Perform cleaning, starting from the DataModel root
    return this.schema.clean(data, cleanOptions, _state);
  }

  /* ---------------------------------------- */

  /**
   * Apply preliminary model-specific cleaning rules or alter cleaning options or initial state.
   * Subclass models may implement this function to configure the cleaning workflow.
   * Any mutations to data, options, or _state parameters are performed inplace.
   * @param {object} data                               The provided input data for cleaning
   * @param {DataModelCleaningOptions} options          Options which define how cleaning should be performed
   * @param {DataModelUpdateState} _state               The data cleaning state
   * @protected
   */
  static _preCleanData(data, options, _state) {
    _state.creation ??= false;

    // Treat provided input data as "source" data for new creations
    if ( _state.creation ) {
      _state.source ??= data;
      _state.modelSource ??= data;
    }

    // Otherwise record existing model source
    else {
      _state.source ??= _state.model?._source;
      _state.modelSource ??= _state.source;
    }
  }

  /* ---------------------------------------- */

  /**
   * Apply final custom model-specific cleaning rules after data schema fields are cleaned.
   * Subclass models can implement this function as an ideal place to apply custom imputation or cleaning.
   * Cleaning must be done in-place rather than returning a different object.
   * @param {object} data                                 The provided input data for cleaning
   * @param {Readonly<DataModelCleaningOptions>} options  Options which define how cleaning was performed
   * @param {DataModelUpdateState} _state                 The data cleaning state
   * @returns {object}                                    The original data object, with cleaning performed inplace
   * @protected
   */
  static _cleanData(data, options, _state) {}

  /* ---------------------------------------- */

  /**
   * Resolve a previously-initialized embedded DataModel that corresponds to an element being cleaned. Called during
   * recursive data cleaning when a parent field contains EmbeddedDataField elements, or a single nested DataModel
   * field (such as a TypeDataField), in order to propagate the inner DataModel into _state.model for the recursive
   * clean operation. A single (non-collection) inner model resolves to itself; collection elements are matched by
   * stable _id when one is present, otherwise by positional index.
   *
   * Subclasses may override when data preparation reshapes the field property in a way that the default cannot
   * interpret, for example wrapping the container in a non-iterable type or replacing it with a derived view.
   *
   * @param {DataField} field                 The schema field being recursed into.
   * @param {object} element
   * @param {object} element.value            The cleaned candidate value for the element being processed.
   * @param {number} [element.index]          The positional index of the element within a container, if applicable.
   * @param {Readonly<DataModelCleaningOptions>} [options]  The cleaning options in effect for the operation.
   * @returns {DataModel|null}                The corresponding previously-initialized inner DataModel, or null.
   * @protected
   */
  _getInnerModel(field, { value, index }={}, options={}) {
    // Compute the field's path relative to this model's schema, walking up the parent chain until reaching it. This
    // ensures lookups traverse intermediate plain SchemaFields rather than accessing the field name directly on the
    // incorrect model.
    const parts = [];
    for ( let f = field; f; f = f.parent ) {
      if ( f.name ) parts.unshift(f.name);
      if ( f.parent === this.schema ) break;
    }
    const path = parts.join(".");
    const container = path ? getProperty(this, path) : null;
    if ( !container ) return null;

    // Case 0 - A single (non-collection) inner DataModel.
    if ( container instanceof DataModel ) return container;

    // Case 1 - A collection element identified by stable _id, which does not require a positional index.
    if ( value?._id ) {
      if ( typeof container.get === "function" ) {
        const m = container.get(value._id);
        return m instanceof DataModel ? m : null;
      }
      if ( !container[Symbol.iterator] ) return null;
      for ( const m of container ) {
        if ( m?._id === value._id ) return m instanceof DataModel ? m : null;
      }
      return null;
    }

    if ( index === undefined ) return null;

    // Case 2 - Basic Array.
    if ( Array.isArray(container) ) {
      const m = container[index];
      return m instanceof DataModel ? m : null;
    }

    // Case 3 - Any other Iterable.
    if ( !container[Symbol.iterator] ) return null;
    let i = 0;
    for ( const m of container ) {
      if ( i === index ) return m instanceof DataModel ? m : null;
      i++;
    }

    return null;
  }

  /* ---------------------------------------- */
  /*  Data Initialization                     */
  /* ---------------------------------------- */

  /**
   * A generator that orders the DataFields in the DataSchema into an expected initialization order.
   * @returns {Generator<[string,DataField]>}
   * @yields {DataField}
   * @protected
   */
  *_initializationOrder() {
    for ( const entry of this.schema.entries() ) yield entry;
  }

  /* ---------------------------------------- */

  /**
   * Initialize the instance by copying data from the source object to instance attributes.
   * This mirrors the workflow of SchemaField#initialize but with some added functionality.
   * @param {object} [options]        Options provided to the model constructor
   * @protected
   */
  _initialize(options={}) {
    for ( const [name, field] of this._initializationOrder() ) {
      const sourceValue = field.persisted ? this._source[name] : field.getInitialValue(this._source);

      // Field initialization
      const value = field.initialize(sourceValue, this, options);
      const isFunction = typeof value === "function";
      const isClass = isFunction && value.prototype && Function.prototype.toString.call(value).startsWith("class ");

      // Special handling for Document IDs
      if ( name === "_id" ) {

        // Skip if the property is not configurable
        if ( Object.getOwnPropertyDescriptor(this, name)?.configurable === false ) continue;

        Object.defineProperty(this, name, {value, writable: false, configurable: !value, enumerable: true});
      }

      // Getter fields
      else if ( isFunction && !isClass ) {

        // Skip if the class defines instance getter already
        if ( name in this.constructor.prototype ) continue;

        // Skip if the instance property is not configurable
        if ( Object.getOwnPropertyDescriptor(this, name)?.configurable === false ) continue;

        Object.defineProperty(this, name, {get: value, configurable: true, enumerable: true});
      }

      // Readonly fields
      else if ( field.readonly ) {

        // Skip if the class defines instance getter already
        if ( name in this.constructor.prototype ) continue;

        // Skip if the instance property is not configurable
        if ( Object.getOwnPropertyDescriptor(this, name)?.configurable === false ) continue;

        // Readonly value fields
        Object.defineProperty(this, name, {value, writable: false, configurable: false, enumerable: true});
      }

      // Writable fields
      else {
        let propertyDescriptor = Object.getOwnPropertyDescriptor(this, name);
        if ( !propertyDescriptor ) {
          const cls = getDefiningClass(this, name);
          if ( cls ) propertyDescriptor = Object.getOwnPropertyDescriptor(cls.prototype, name);
        }

        // Skip if the property is not assignable
        if ( !propertyDescriptor || (propertyDescriptor.writable === true) || propertyDescriptor.set ) {
          this[name] = value;
        }
      }
    }
  }

  /* ---------------------------------------- */

  /**
   * Reset the state of this data instance back to mirror the contained source data, erasing any changes.
   */
  reset() {
    this._initialize();
  }

  /* ---------------------------------------- */

  /**
   * Clone a model, creating a new data model by combining current data with provided overrides.
   * @param {object} [data={}]             Additional data which overrides current document data at the time of creation
   * @param {DataModelConstructionContext} [context={}]          Context options passed to the data model constructor
   * @returns {DataModel|Promise<DataModel>} The cloned instance
   */
  clone(data={}, context={}) {
    data = mergeObject(this.toObject(), data, {insertKeys: false, applyOperators: true, inplace: true});
    return new this.constructor(data, {parent: this.parent, ...context, strict: false});
  }

  /* ---------------------------------------- */
  /*  Data Validation Methods                 */
  /* ---------------------------------------- */

  /**
   * Validate the data contained in the document to check for type and content.
   * This method is intended to validate complete model records, verifying both individual field validation as well
   * as joint model validity.
   *
   * For validating sets of partial model changes, it is preferred to call DataModel#updateSource as a `dryRun`. This
   * method provides a convenience alias for such a workflow if `changes` are provided.
   *
   * Warning: if fallback handling is allowed, this process will mutate provided changes or model source data.
   *
   * @param {DataModelValidationOptions} options    Options which modify how the model is validated
   * @returns {boolean}                             Whether the data source or proposed change is reported as valid.
   *                                                A boolean is always returned if validation is non-strict.
   * @throws {DataModelValidationError}             An error thrown if validation is strict and a failure occurs.
   */
  validate({changes, clean, dropInvalidEmbedded=false, strict=true, fallback=false, fields=true, joint=true}={}) {

    // Validate a set of changes via a dryRun of updateSource
    if ( changes ) {
      const cleanOptions = (clean === false) ? false : Object.assign({
        addTypes: clean === true, // Impute types into the cleaned changes
        copy: true,               // Copy provided changes unless prevented
        prune: true,              // Prune changes unless prevented
        persisted: true,          // Likewise with non-persisted data
        migrate: false,           // Validate changes without migrations
        model: clean === true,    // Model-level cleaning is not performed unless specifically requested
        sanitize: false           // Validate changes without sanitizations
      }, clean, {                 // User-provided inputs
        partial: true             // Always do partial cleaning in case required fields are missing in changes
      });
      try {
        this.updateSource(changes, {clean: cleanOptions, dropInvalidEmbedded, fallback, dryRun: true});
      } catch(err) {
        if ( strict ) throw err;
        return false;
      }
      return true;
    }

    // Otherwise, we are validating a complete set of model source
    const data = this._source;

    // Validate individual fields in the data or in a specific change-set, throwing errors if validation fails
    if ( fields ) {
      this.#validationFailures.fields = null;
      const options = {dropInvalidEmbedded, fallback, model: this, partial: false, strict: false}; // Non-strict so we capture errors
      const err = this.schema.validate(data, options);
      if ( err ) {
        const failure = this.#createValidationFailure(err);
        this.#validationFailures.fields = failure;
        if ( strict && failure.unresolved ) throw failure.asError();
        else logger.warn(failure);
      }
    }

    // Perform joint document-level validations which consider all fields together
    if ( joint ) {
      this.#validationFailures.joint = null;
      try {
        this.schema._validateModel(data);     // Validate inner models
        this.constructor.validateJoint(data); // Validate this model
      } catch ( err ) {
        const failure = this.#createValidationFailure(err, {joint: true});
        this.#validationFailures.joint = failure;
        if ( strict ) throw failure.asError();
        else logger.warn(failure);
      }
    }
    return !this.invalid;
  }

  /* ---------------------------------------- */

  /**
   * Evaluate joint validation rules which apply validation conditions across multiple fields of the model.
   * Field-specific validation rules should be defined as part of the DataSchema for the model.
   * This method allows for testing aggregate rules which impose requirements on the overall model.
   * @param {object} data     Candidate data for the model
   * @throws {Error}          An error if a validation failure is detected
   */
  static validateJoint(data) {}

  /* ---------------------------------------- */

  /**
   * Create a DataModelValidationFailure instance for use in this DataModel.
   * @param {Error} err         Some other error which represents an underlying root cause
   * @param {object} [options]  Options which inform error structure
   * @param {boolean} [options.joint=false] Is this a joint validation failure?
   * @returns {DataModelValidationFailure}
   */
  #createValidationFailure(err, {joint=false}={}) {

    // Construct the failure
    const id = this.uuid ?? [this.constructor.name, this._source.id].filterJoin(".");
    const msg = `[${id}] validation errors:`;
    const options = {fieldPath: "_source", unresolved: true};
    if ( err ) options.cause = err;
    const failure = new DataModelValidationFailure(msg, options);

    // Update content
    if ( err instanceof DataModelValidationFailure ) err.copyTo(failure);
    if ( joint ) failure.joint = err.message;
    else failure.message += ` ${err.message}`;
    failure.message = failure.toString(); // Finalize the message string
    return failure;
  }

  /* ---------------------------------------- */
  /*  Data Management                         */
  /* ---------------------------------------- */

  /**
   * Update the DataModel locally by applying an object of changes to its source data.
   * The provided changes are expanded, cleaned, validated, and stored to the source data object for this model.
   * The provided changes argument is mutated in this process.
   * The source data is then re-initialized to apply those changes to the prepared data.
   * The method returns an object of differential changes which modified the original data.
   *
   * @param {object} changes                  New values which should be applied to the data model
   * @param {DataModelUpdateOptions} options  Options which determine how the new data is merged
   * @returns {object}                        An object containing differential keys and values that were changed
   * @throws {DataModelValidationError}       An error if the requested data model changes were invalid
   */
  updateSource(changes={}, options={}) {

    // Prepare data modification operation
    const _state = {model: this, source: this._source, modelSource: this._source, user: options.user};
    this._preUpdateSource(changes, options, _state);

    // Clean provided input data unless explicitly prevented
    if ( options.clean ) changes = this.constructor.cleanData(changes, options.clean, _state);

    // Root-level keys in non-recursive changes are handled as ForcedReplacement operations
    if ( options.recursive === false ) DataModel.#performNonRecursiveUpdate(changes);

    // Perform updates on the safe copy of source data
    const copy = this.#prepareSafeSource(changes);
    let diff;
    try {
      diff = this._updateDiff(copy, changes, options, _state);
    } catch(err) {
      if ( err instanceof DataModelValidationFailure ) throw err.asError();
      throw err;
    }

    // Model-level joint validation
    try {
      this.constructor.validateJoint(copy);
    } catch(err) {
      throw this.#createValidationFailure(err, {joint: true}).asError();
    }

    // Commit the final changes
    if ( isEmpty(diff) || options.dryRun ) return diff;
    this._updateCommit(copy, diff, options, _state);
    if ( this.invalid ) this.validate({joint: true}); // Determine whether an invalid state was cured?
    return diff;
  }

  /* ---------------------------------------- */

  /**
   * Prepare the state object that is transacted through an updateSource operation.
   * @param {object} changes                  New values which should be applied to the data model
   * @param {DataModelUpdateOptions} options  Options which determine how the new data is merged
   * @param {DataModelUpdateState} _state     Data model update state
   * @protected
   */
  _preUpdateSource(changes, options, _state) {

    // Define standard cleaning options
    const cleanOptions = Object.assign({
      addTypes: false,    // Require explicit polymorphic data types
      copy: true,         // Copy provided changes unless prevented
      prune: true,        // Prune changes unless prevented
      migrate: true,      // Migrate data unless prevented
      sanitize: true      // Sanitize input unless prevented
    }, options.clean, {   // User-provided inputs
      partial: true,      // Always do partial cleaning
      persisted: true     // Always prune non-persisted data
    });
    if ( typeof cleanOptions.sanitize !== "object" ) cleanOptions.sanitize = cleanOptions.sanitize ? {} : false;

    // Record pre-cleaning _state flags if the cleaning workflow is skipped
    if ( options.clean === false ) this.constructor._preCleanData(changes, cleanOptions, _state);
    else options.clean = cleanOptions;
  }

  /* ---------------------------------------- */

  /**
   * Perform the first step of the DataModel#_updateSource workflow which applies changes to a copy of model source
   * data and records the resulting diff.
   * @param {object} copy                     A mutable copy of model source data
   * @param {object} changes                  New values which should be applied to the data model
   * @param {DataModelUpdateOptions} options  Options which determine how the new data is merged
   * @param {DataModelUpdateState} _state     Data cleaning state
   * @returns {object}                        The resulting difference applied to source data
   * @throws {DataModelValidationFailure}     A failure if the proposed change is invalid
   * @protected
   */
  _updateDiff(copy, changes, options, _state) {

    // Prepare state to transact throughout the model
    const rootKey = "_source";
    const logId = (this.persisted ? this.uuid : this._id) ?? "";
    const msg = `${this.constructor.name} ${logId ? `[${logId}] ` : ""}validation errors: `;
    const failure = new DataModelValidationFailure(msg, {fieldPath: rootKey, unresolved: false});
    _state = {..._state, source: {[rootKey]: copy}, diff: {[rootKey]: {}}, failure};

    // Required by validation, TODO refactor to use _state.model instead
    options.model = this;

    // Recursive update starting at the root schema
    this.schema._updateDiff(rootKey, changes, options, _state);

    // Handle failure
    const f = failure.fields[rootKey];
    if ( f && !f.empty && f.unresolved ) {
      failure.message += f.toString();
      f.copyTo(failure);
      throw failure;
    }

    // Return the root diff
    return _state.diff[rootKey] || {};
  }

  /* ---------------------------------------- */

  /**
   * Perform the second step of the DataModel#_updateSource workflow which applies the prepared diff to the model.
   * @param {object} copy                     The prepared copy of source data with changes applied
   * @param {object} diff                     The differential changes that were applied to source
   * @param {DataModelUpdateOptions} options  Options which determine how the new data is merged
   * @param {DataModelUpdateState} _state     Data cleaning state which might include instructions for final commit
   * @protected
   */
  _updateCommit(copy, diff, options, _state) {
    this.schema._updateCommit(this, "_source", copy, diff, options);
    this._initialize();
  }

  /* ---------------------------------------- */

  /**
   * Prepare a mutation-safe version of the source data.
   * The resulting object contains a copy of the source data for any field present in the proposed set of model changes.
   * For fields not present in the proposed changes, the resulting object directly references the true source.
   * This approach is used because of superior performance for complex data structures.
   * @param {object} changes
   * @returns {object}
   */
  #prepareSafeSource(changes) {
    const copy = {};
    for ( const k of iterateKeys(changes) ) {
      if ( k in this._source ) copy[k] = deepClone(this._source[k]);  // Copy for changed fields
    }
    for ( const k of iterateKeys(this._source) ) {
      if ( !(k in copy) ) copy[k] = this._source[k];                  // Direct reference for unchanged fields
    }
    return copy;
  }

  /* ---------------------------------------- */
  /*  Serialization and Storage               */
  /* ---------------------------------------- */

  /**
   * Copy and transform the DataModel into a plain object.
   * Draw the values of the extracted object from the data source (by default) otherwise from its transformed values.
   * @param {boolean} [source=true]     Draw values from the underlying data source rather than transformed values
   * @returns {object}                  The extracted primitive object
   */
  toObject(source=true) {
    if ( source ) return deepClone(this._source);

    // We have use the schema of the class instead of the schema of the instance to prevent an infinite recursion:
    // the EmbeddedDataField replaces the schema of its model instance with itself
    // and EmbeddedDataField#toObject calls DataModel#toObject.
    return this.constructor.schema.toObject(this);
  }

  /* ---------------------------------------- */

  /**
   * Extract the source data for the DataModel into a simple object format that can be serialized.
   * @returns {object}          The document source data expressed as a plain object
   */
  toJSON() {
    return this.toObject(true);
  }

  /* -------------------------------------------- */

  /**
   * Create a new instance of this DataModel from a source record.
   * The source data is presumed trustworthy and is not strictly validated unless explicitly requested.
   * @param {object} source    Initial document data which comes from a trusted source.
   * @param {Omit<DataModelConstructionContext, "strict"> & DataModelFromSourceOptions} [context]
   *                           Model construction context
   * @returns {DataModel}
   */
  static fromSource(source, {strict=false, ...context}={}) {
    return new this(source, {strict, ...context});
  }

  /* ---------------------------------------- */

  /**
   * Create a DataModel instance using a provided serialized JSON string.
   * @param {string} json       Serialized document data in string format
   * @returns {DataModel}       A constructed data model instance
   */
  static fromJSON(json) {
    return this.fromSource(JSON.parse(json));
  }

  /* -------------------------------------------- */

  /**
   * Convert each value of a changes object to be a ForcedReplacement operator.
   * @param {object} changes        An object of changes which will be mutated inplace
   */
  static #performNonRecursiveUpdate(changes) {
    for ( const [k, v] of iterateEntries(changes) ) {
      if ( k === "_id" ) continue; // DataModel#_id can never be a ForcedReplacement
      if ( !(v instanceof operators.DataFieldOperator) ) {
        changes[k] = operators.ForcedReplacement.create(v);
      }
    }
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * Wrap data migration in a try/catch which attempts it safely.
   *
   * @param {object} source                       Candidate source data for the module, before further cleaning
   * @param {Readonly<DataModelCleaningOptions>} [options]  Additional options for how the field is cleaned
   * @returns {object}                            Migrated source data, ready for further cleaning
   */
  static migrateDataSafe(source, options={}) {
    try {
      source = this.migrateData(source, options);
    } catch(err) {
      err.message = `Failed data migration for ${this.name}: ${err.message}`;
      logger.warn(err);
    }
    return source;
  }

  /* ---------------------------------------- */

  /**
   * Migrate candidate source data for this DataModel which may require initial cleaning or transformations.
   * @param {object} source                       Candidate source data for the module, before further cleaning
   * @param {Readonly<DataModelCleaningOptions>} [options]  Additional options for how the field is cleaned
   * @returns {object}                            Migrated source data, ready for further cleaning
   */
  static migrateData(source, options) {
    return source;
  }

  /* ---------------------------------------- */

  /**
   * Take data which conforms to the current data schema and add backwards-compatible accessors to it in order to
   * support older code which uses this data.
   * @param {object} data         Data which matches the current schema
   * @param {object} [options={}] Additional shimming options
   * @param {boolean} [options.embedded=true] Apply shims to embedded models?
   * @returns {object}            Data with added backwards-compatible properties, which is the same object as
   *                              the `data` argument
   */
  static shimData(data, {embedded=true}={}) {
    if ( Object.isSealed(data) ) return data;
    const schema = this.schema;
    if ( embedded ) {
      for ( const [name, value] of Object.entries(data) ) {
        const field = schema.get(name);
        if ( (field instanceof EmbeddedDataField) && !Object.isSealed(value) ) {
          data[name] = field.model.shimData(value || {});
        }
        else if ( field instanceof EmbeddedCollectionField ) {
          for ( const d of (value || []) ) {
            if ( !Object.isSealed(d) ) field.model.shimData(d);
          }
        }
      }
    }
    return data;
  }

  /* ---------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  static *_initializationOrder() {
    throw new Error(`DataModel._initializationOrder is no longer a static generator and must be called at an instance
    level DataModel#_initializationOrder`);
  }
}
