import {isEmpty} from "../utils/helpers.mjs";

/**
 * @typedef DataModelValidationFailureElement
 * @property {string|number} id                    Either the element's index or some other identifier for it.
 * @property {string} [name]                       Optionally a user-friendly name for the element.
 * @property {DataModelValidationFailure} failure  The element's validation failure.
 */

/**
 * A class responsible for recording information about a validation failure.
 */
export class DataModelValidationFailure {
  constructor(message="", {invalidValue, fallbackValue, fieldPath, dropped=false, unresolved=false, ...options}={}) {
    this.invalidValue = invalidValue;
    this.fallbackValue = fallbackValue;
    this.fieldPath = fieldPath;
    this.dropped = dropped;
    this.unresolved = unresolved;
    this.message = message;
    this.options = options;
  }

  /**
   * The value that failed validation for this field.
   * @type {any}
   */
  invalidValue;

  /**
   * The value it was replaced by, if any.
   * @type {any}
   */
  fallbackValue;

  /**
   * The path of the field responsible for the failure.
   * @type {string}
   */
  fieldPath;

  /**
   * Whether the value was dropped from some parent collection.
   * @type {boolean}
   */
  dropped;

  /**
   * If this field contains other fields that are validated as part of its validation, their results are recorded here.
   * @type {Record<string, DataModelValidationFailure>}
   */
  fields = {};

  /**
   * If this field contains a list of elements that are validated as part of its validation, their results are recorded
   * here.
   * @type {DataModelValidationFailureElement[]}
   */
  elements = [];

  /**
   * If this field has a joint validation failure across multiple sub-fields, the failure message is recorded here.
   * @type {string}
   */
  joint;

  /**
   * The error message.
   * @type {string}
   */
  message;

  /**
   * Options forwarded to the Error constructor.
   * @type {Error}
   */
  options;

  /**
   * Record whether a validation failure is unresolved.
   * This reports as true if validation for this field or any hierarchically contained field is unresolved.
   * A failure is unresolved if the value was invalid and there was no valid fallback value available.
   * @type {boolean}
   */
  unresolved = false;

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

  /**
   * Whether this failure contains other sub-failures.
   * @type {boolean}
   */
  get empty() {
    return isEmpty(this.fields) && isEmpty(this.elements) && !this.joint;
  }

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

  /**
   * Return this validation failure as an Error instance.
   * @returns {DataModelValidationError}
   */
  asError() {
    return new DataModelValidationError(this, this.options);
  }

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

  /**
   * Copy the data of this DataModeValidationFailure to another one.
   * @param {DataModelValidationFailure} failure
   */
  copyTo(failure) {
    failure.invalidValue = this.invalidValue;
    failure.fallbackValue = this.fallbackValue;
    failure.fieldPath = this.fieldPath;
    failure.dropped = this.dropped;
    failure.unresolved = this.unresolved;
    failure.fields = this.fields;
    failure.elements = this.elements;
    failure.joint = this.joint;
  }

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

  /**
   * Retrieve the leaf node failure that caused this, or a specific sub-failure via a path.
   * @param {string} [key]  The property key to the failure.
   * @returns {DataModelValidationFailure|null}
   *
   * @example Retrieving a failure.
   * ```js
   * const changes = {
   *   "foo.bar": "validValue",
   *   "foo.baz": "invalidValue"
   * };
   * try {
   *   doc.validate(expandObject(changes));
   * } catch ( err ) {
   *   const failure = err.getFailure("foo.baz");
   *   console.log(failure.invalidValue); // "invalidValue"
   * }
   * ```
   */
  getFailure(key) {
    if ( !key ) return this;
    const parts = key.split(".");
    const k = parts.shift();
    let failure;
    if ( k in this.fields ) failure = this.fields[k];
    else if ( this.elements.length ) failure = this.elements.find(f => f.id?.toString() === k);
    if ( failure instanceof DataModelValidationFailure ) return failure.getFailure(parts.join("."));
    return null;
  }

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

  /**
   * Retrieve a flattened object of all the properties that failed validation as part of this error.
   * @returns {Record<string, DataModelValidationFailure>}
   *
   * @example Removing invalid changes from an update delta.
   * ```js
   * const changes = {
   *   "foo.bar": "validValue",
   *   "foo.baz": "invalidValue"
   * };
   * try {
   *   doc.validate(expandObject(changes));
   * } catch ( err ) {
   *   const failures = err.getAllFailures();
   *   if ( failures ) {
   *     for ( const prop in failures ) delete changes[prop];
   *     doc.validate(expandObject(changes));
   *   }
   * }
   * ```
   */
  getAllFailures() {
    return DataModelValidationFailure.#aggregateFailures(this);
  }

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

  /**
   * Return the base properties of this failure, omitting any nested failures.
   * @returns {{invalidValue: any, fallbackValue: any, dropped: boolean, message: string}}
   */
  toObject() {
    const {invalidValue, fallbackValue, dropped, message} = this;
    return {invalidValue, fallbackValue, dropped, message};
  }

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

  /**
   * Collect nested failures into an aggregate object.
   * @param {DataModelValidationFailure} failure            The failure instance
   * @returns {Record<string, DataModelValidationFailure>}  Returns the failure at the leaf of the tree, otherwise an
   *                                                        object of sub-failures.
   */
  static #aggregateFailures(failure) {
    const failures = {};
    if ( failure.empty ) return failures;
    const recordSubFailures = (field, subFailures) => {
      if ( subFailures instanceof DataModelValidationFailure ) failures[field] = subFailures;
      else {
        for ( const [k, v] of Object.entries(subFailures) ) failures[`${field}.${k}`] = v;
      }
    };
    for ( const [field, subFailure] of Object.entries(failure.fields) ) {
      recordSubFailures(field, DataModelValidationFailure.#aggregateFailures(subFailure));
    }
    for ( const element of failure.elements ) {
      recordSubFailures(element.id, DataModelValidationFailure.#aggregateFailures(element.failure));
    }
    return failures;
  }

  /* -------------------------------------------- */
  /*  Formatting Helpers                          */
  /* -------------------------------------------- */

  /**
   * Represent the DataModelValidationFailure as a string.
   * @returns {string}
   */
  toString() {
    return DataModelValidationFailure.#formatString(this);
  }

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

  /**
   * Format a DataModelValidationFailure instance as a string message.
   * @param {DataModelValidationFailure} failure    The failure instance
   * @param {number} _d                             An internal depth tracker
   * @returns {string}                              The formatted failure string
   */
  static #formatString(failure, _d=0) {
    let message = failure.message ?? "";
    _d++;
    if ( !isEmpty(failure.fields) ) {
      message += "\n";
      const messages = [];
      for ( const [key, subFailure] of Object.entries(failure.fields) ) {
        const subMessage = DataModelValidationFailure.#formatString(subFailure, _d);
        messages.push(`${" ".repeat(2 * _d)}${key}: ${subMessage}`);
      }
      message += messages.join("\n");
    }
    if ( !isEmpty(failure.elements) ) {
      message += "\n";
      const messages = [];
      for ( const element of failure.elements ) {
        const subMessage = DataModelValidationFailure.#formatString(element.failure, _d);
        messages.push(`${" ".repeat(2 * _d)}${element.id}: ${subMessage}`);
      }
      message += messages.join("\n");
    }
    if ( !isEmpty(failure.joint) ) message += `\n  Joint Validation: ${failure.joint}`;
    return message;
  }

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

  /**
   * Log the validation error as a table.
   */
  logAsTable() {
    const failures = this.getAllFailures();
    if ( isEmpty(failures) ) return;
    console.table(Object.entries(failures).reduce((table, [p, failure]) => {
      table[p] = failure.toObject();
      return table;
    }, {}));
  }

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

  /**
   * Generate a nested tree view of the error as an HTML string.
   * @returns {string}
   */
  asHTML() {
    const renderFailureNode = failure => {
      if ( failure.empty ) return `<li>${foundry.utils.escapeHTML(failure.message || "")}</li>`;
      const nodes = [];
      for ( const [field, subFailure] of Object.entries(failure.fields) ) {
        nodes.push(`<li><details><summary>${field}</summary><ul>${renderFailureNode(subFailure)}</ul></details></li>`);
      }
      for ( const element of failure.elements ) {
        const name = element.name || element.id;
        const html = `
          <li><details><summary>${name}</summary><ul>${renderFailureNode(element.failure)}</ul></details></li>
        `;
        nodes.push(html);
      }
      if ( failure.joint ) nodes.push(`<li>${foundry.utils.escapeHTML(failure.joint)}</li>`);
      return nodes.join("");
    };
    return `<ul class="summary-tree">${renderFailureNode(this)}</ul>`;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  isEmpty() {
    foundry.utils.logCompatibilityWarning(`The DataModelValidationFailure#isEmpty method is deprecated in favor of the
      DataModelValidationFailure#empty property.`, {since: 14, until: 16});
    return this.empty;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get fallback() {
    foundry.utils.logCompatibilityWarning(`DataModelValidationFailure#fallback is renamed to
      DataModelValidationFailure#fallbackValue`, {since: 14, until: 16});
    return this.fallbackValue;
  }
}

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

/**
 * A specialized Error to indicate a model validation failure.
 * @extends {Error}
 */
export class DataModelValidationError extends Error {
  /**
   * @param {DataModelValidationFailure|string} failure  The failure that triggered this error or an error message
   * @param {...any} [params]                            Additional Error constructor parameters
   */
  constructor(failure, ...params) {
    const message = typeof failure === "string" ? failure : failure.message;
    super(message, ...params);
    if ( failure instanceof DataModelValidationFailure ) this.#failure = failure;
  }

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

  /**
   * The root validation failure that triggered this error.
   * @type {DataModelValidationFailure}
   */
  #failure;

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

  /**
   * @see {@link DataModelValidationFailure#getFailure}
   * @param {string} [key]
   * @returns {DataModelValidationFailure|null}
   */
  getFailure(key) {
    return this.#failure?.getFailure(key) ?? null;
  }

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

  /**
   * @see {@link DataModelValidationFailure#getAllFailures}
   * @returns {Record<string, DataModelValidationFailure>}
   */
  getAllFailures() {
    return this.#failure?.getAllFailures() ?? {};
  }

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

  /**
   * Log the validation error as a table.
   */
  logAsTable() {
    this.#failure?.logAsTable();
  }

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

  /**
   * Generate a nested tree view of the error as an HTML string.
   * @returns {string}
   */
  asHTML() {
    return this.#failure?.asHTML() ?? "";
  }

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

  /** @inheritDoc */
  toString() {
    return this.#failure?.toString() ?? super.toString();
  }
}
