Source

rez_view.js

//-----------------------------------------------------------------------------
// Expression Evaluation
//-----------------------------------------------------------------------------

/**
 * @function evaluateExpression
 * @description Evaluates a JavaScript expression string with access to provided bindings.
 *
 * Creates a sandboxed function that can access binding values by name,
 * then executes it with those values. Used by templates for conditionals
 * and dynamic expressions.
 *
 * @param {string} expression - The JavaScript expression to evaluate
 * @param {Object} bindings - Object mapping variable names to their values
 * @param {boolean} [rval=true] - If true, wraps expression in return statement
 * @returns {*} The result of evaluating the expression
 *
 * @example
 * // Evaluate a conditional
 * evaluateExpression("score > 10", { score: 15 }); // returns true
 *
 * @example
 * // Evaluate without return value (for side effects)
 * evaluateExpression("console.log(name)", { name: "Player" }, false);
 */
function evaluateExpression(expression, bindings, rval = true) {
  const proxy = new Proxy(
    {},
    {
      get: (target, property) => {
        if (bindings.hasOwnProperty(property)) {
          return bindings[property];
        }
        return undefined;
      },
    }
  );

  const argNames = Object.keys(bindings);
  const argValues = argNames.map((name) => proxy[name]);

  // Create a new function with bindings as arguments and the expression as the body
  let func;
  if(rval) {
    func = new Function(...argNames, `return ${expression};`);
  } else {
    func = new Function(...argNames, `${expression}`);
  }

  // Invoke the function with the values from the bindings
  return func(...argValues);
}

//-----------------------------------------------------------------------------
// Block
//-----------------------------------------------------------------------------

/**
 * @class RezBlock
 * @category Internal
 * @description Represents a renderable block of content within the view hierarchy.
 *
 * A block wraps a source element (typically a card or other game element)
 * and handles:
 * - Resolving data bindings from the source element
 * - Rendering the source's template with bound values
 * - Managing nested blocks
 * - Generating HTML output with appropriate CSS classes
 *
 * Blocks can be nested within layouts to create complex view hierarchies.
 * Each block maintains a reference to its parent block, enabling bindings
 * to flow down through the hierarchy.
 *
 * Special $ attributes recognized by RezBlock:
 * - `$debug_bindings` - When true, logs binding information to console during render
 * - `$suppress_wrapper` - When true, omits the wrapper `<div>` around block content
 * - `$parent` - Reference to the parent source element in the hierarchy
 */
class RezBlock {
  /** @type {RezBlock|null} */
  #parentBlock;
  /** @type {string} */
  #blockType;
  /** @type {Object} */
  #source;
  /** @type {boolean} */
  #flipped;
  /** @type {Object} */
  #params;

  /**
   * @function constructor
   * @memberof RezBlock
   * @description Creates a new RezBlock.
   *
   * @param {string} blockType - The type of block ("block" or "card")
   * @param {Object} source - The source element to render (e.g., a RezCard)
   * @param {Object} [params={}] - Additional parameters passed to the template
   */
  constructor(blockType, source, params = {}) {
    this.#parentBlock = null;
    this.#blockType = blockType;
    this.#source = source;
    this.#flipped = false;
    this.#params = params;
  }

  /**
   * The parent block in the view hierarchy.
   * @type {RezBlock|null}
   */
  get parentBlock() {
    return this.#parentBlock;
  }

  set parentBlock(block) {
    this.#parentBlock = block;
  }

  /**
   * The type of this block ("block" or "card").
   * @type {string}
   */
  get blockType() {
    return this.#blockType;
  }

  set blockType(type) {
    this.#blockType = type;
  }

  /**
   * The source element being rendered.
   * @type {Object}
   */
  get source() {
    return this.#source;
  }

  set source(source) {
    this.#source = source;
  }

  /**
   * Whether this block is showing its flipped (back) side.
   * @type {boolean}
   */
  get flipped() {
    return this.#flipped;
  }

  set flipped(is_flipped) {
    this.#flipped = is_flipped;
  }

  /**
   * Additional parameters passed to the template.
   * @type {Object}
   */
  get params() {
    return this.#params;
  }

  set params(params) {
    this.#params = params;
  }

  /**
   * Resolves an element ID to its corresponding game element.
   *
   * @param {string} id - The element ID to resolve
   * @returns {Object} The resolved game element
   */
  instantiateIdBinding(id) {
    return $(id);
  }

  /**
   * Resolves a property reference to its value.
   *
   * @param {Object} ref - Property reference with elem_id and attr_name
   * @param {string} ref.elem_id - The element ID
   * @param {string} ref.attr_name - The attribute name to read
   * @returns {*} The attribute value
   */
  instantiatePropertyBinding(ref) {
    const target = $(ref.elem_id);
    return target[ref.attr_name];
  }

  /**
   * Invokes a function binding with the current context.
   *
   * @param {Object} bindings - Current bindings object
   * @param {Function} f - The function to invoke
   * @returns {*} The function's return value
   */
  instantiateFunctionBinding(bindings, f) {
    if (this.parentBlock) {
      return f(this, this.parentBlock.source, bindings);
    } else {
      return f(this, null, bindings);
    }
  }

  /**
   * Resolves a binding path function against the source.
   *
   * @param {Function} p - Path function to invoke
   * @returns {*} The resolved value
   */
  instantiateBindingPath(p) {
    return p(this.source);
  }

  /**
   * Resolves a path binding function with current bindings.
   *
   * @param {Function} path_fn - Path function to invoke
   * @param {Object} bindings - Current bindings object
   * @returns {*} The resolved value
   */
  instantiatePathBinding(path_fn, bindings) {
    return path_fn(bindings);
  }

  /**
   * Resolves a binding object to its actual value.
   *
   * Handles literal values directly, otherwise delegates to extractBindingValue
   * for source-based resolution.
   *
   * @param {Object} bindings - Current bindings object
   * @param {Object} bindingObject - The binding specification
   * @param {*} [bindingObject.literal] - A literal value (used directly if present)
   * @param {*} [bindingObject.source] - Source for value resolution
   * @param {boolean} [bindingObject.deref] - Whether to dereference the result
   * @returns {*} The resolved binding value
   * @throws {Error} If source is undefined or null when no literal is provided
   */
  resolveBindingValue(bindings, bindingObject) {
    const { source, literal, deref } = bindingObject;

    // Handle literal values first
    if (literal !== undefined) {
      return literal;
    }

    // Validate source
    if (source === undefined || source === null) {
      throw new Error('Binding source is undefined or null');
    }

    // Resolve binding based on source type
    return this.extractBindingValue(bindings, source, deref);
  }

  /**
   * Extracts a value from a binding source.
   *
   * Supports multiple source types:
   * - String: Treated as an element ID
   * - Element reference ({$ref: "id"}): Resolved to the element
   * - Function: Invoked with block context
   * - Object with binding function: Path-based resolution
   *
   * @param {Object} bindings - Current bindings object
   * @param {string|Function|Object} source - The binding source
   * @param {boolean} [deref=false] - Whether to dereference the result
   * @returns {*} The extracted value
   * @throws {Error} If source type is not recognized
   */
  extractBindingValue(bindings, source, deref = false) {
    let value;
    if (typeof source === "string") {
      value = this.instantiateIdBinding(source);
    } else if(Rez.isElementRef(source)) {
      value = this.instantiateIdBinding(source.$ref);
    } else if (typeof source === "function") {
      value = this.instantiateFunctionBinding(bindings, source);
    } else if (source && typeof source.binding === "function") {
      value = this.instantiatePathBinding(source.binding, bindings);

      // Apply dereferencing only for path bindings when deref is true
      if (deref) {
        value = this.dereferenceBoundValue(value);
      }
    } else {
      // Detailed error for unrecognized source type
      throw new Error(`Invalid binding source type: ${typeof source}.
        Expected string, function, or object with binding function.`);
    }

    return value;
  }

  /**
   * Dereferences element references to their actual elements.
   *
   * Handles both single values and arrays of values.
   *
   * @param {string|Object|Array} value - Value(s) to dereference
   * @returns {Object|Array<Object>} The dereferenced element(s)
   */
  dereferenceBoundValue(value) {
    if (Array.isArray(value)) {
      return value.map(ref => $(ref));  // $(ref) now handles both string and {$ref: "id"} formats
    }

    return $(value);  // $(value) now handles both string and {$ref: "id"} formats
  }

  /**
   * Builds the bindings object from the source element's bindings attribute.
   *
   * Processes each binding specification, resolving sources to values and
   * adding them to the bindings object with their specified prefixes.
   *
   * @param {Object} initialBindings - Initial bindings to extend
   * @returns {Object} The complete bindings object
   */
  getBindings(initialBindings) {
    const sourceBindings = this.source.getAttributeValue("bindings", []);

    if(this.source.getAttributeValue("$debug_bindings", false)) {
      console.log(`Binding source: ${this.source.id}`);
      console.log("Initial Bindings");
      console.dir(initialBindings);

      console.log("Bindings");
      console.dir(sourceBindings);
    }

    return sourceBindings.reduce((bindings, bindingObject) => {
      const prefix = bindingObject["prefix"];
      const value = this.resolveBindingValue(bindings, bindingObject);

      bindings[prefix] = value;
      return bindings;
    }, initialBindings);
  }

  /**
   * Creates the complete value bindings for template rendering.
   *
   * Combines parent bindings with this block's bindings, adding:
   * - `block`: Reference to this block
   * - `params`: The params object
   * - `source`: The source element
   * - Source's bindAs name: The source element (aliased)
   *
   * @returns {Object} Complete bindings object for template rendering
   */
  bindValues() {
    const initialBindings = {
      ...this.parentBindings(),
      block: this,
      params: this.params,
      source: this.source,
      [this.source.bindAs()]: this.source
    };

    return this.getBindings(initialBindings);
  }

  /**
   * Retrieves and instantiates nested blocks from the source's blocks attribute.
   *
   * Each block specification defines a binding name and source element.
   * The blocks are instantiated as RezBlock instances with this block as parent.
   *
   * @returns {Object} Map of binding names to RezBlock instances
   */
  getBlocks() {
    const blocks = this.source.getAttributeValue("blocks", []);
    return blocks.reduce((blockMappings, item) => {
      // Binding: {prefix: "name", source: {$ref: "card_id"}}
      const bindingName = item.prefix;
      const blockSource = $(item.source);
      blockSource.$parent = this.source;
      const block = new RezBlock("block", blockSource);
      block.parentBlock = this;
      blockMappings[bindingName] = block;
      return blockMappings;
    }, {});
  }

  /**
   * Renders all nested blocks to HTML.
   *
   * @returns {Object} Map of binding names to rendered HTML strings
   */
  bindBlocks() {
    return this.getBlocks().objMap((block) => block.html());
  }

  /**
   * Gets the view template function for this block.
   *
   * @returns {Function} Template function that accepts bindings and returns HTML
   */
  getViewTemplate() {
    return this.source.getViewTemplate(this.flipped);
  }

  /**
   * Gets bindings from the parent block, if any.
   *
   * @returns {Object} Parent bindings or empty object
   */
  parentBindings() {
    if (this.parentBlock) {
      return this.parentBlock.bindValues();
    } else {
      return {};
    }
  }

  /**
   * Computes complete bindings including values and rendered blocks.
   *
   * @returns {Object} Complete bindings object
   */
  bindings() {
    const bindings = {
      ...this.bindValues(),
      ...this.bindBlocks(),
    };

    return bindings;
  }

  /**
   * Renders the block content using its template and bindings.
   *
   * @returns {string} Rendered HTML content
   */
  renderBlock() {
    const template = this.getViewTemplate();
    const bindings = this.bindings();
    return template(bindings);
  }

  /**
   * Gets the CSS classes for this block's wrapper element.
   *
   * @returns {string} Space-separated CSS class names
   * @throws {Error} If block type is not recognized
   */
  css_classes() {
    if (this.blockType == "block") {
      return "rez-block";
    } else if (this.blockType == "card") {
      if (this.flipped) {
        return "rez-card rez-flipped-card";
      } else {
        return "rez-card rez-active-card";
      }
    } else {
      throw new Error(`Attempt to get css_classes for unexpected block type: '${this.blockType}'`);
    }
  }

  /**
   * Renders this block to HTML with its wrapper div.
   *
   * If the source has `$suppress_wrapper` set, returns content without wrapper.
   *
   * @returns {string} Complete HTML for this block
   */
  html() {
    const blockContent = this.renderBlock();

    if(this.source.$suppress_wrapper) {
      return blockContent;
    } else {
      return `<div class="${this.css_classes()}">${blockContent}</div>`;
    }
  }

  /**
   * Creates a copy of this block.
   *
   * @returns {RezBlock} A new block with the same properties
   */
  copy() {
    const copy = new RezBlock(this.blockType, this.source, this.params);
    copy.parentBlock = this.parentBlock;
    copy.flipped = this.flipped;
    return copy;
  }
};

window.Rez.RezBlock = RezBlock;

//-----------------------------------------------------------------------------
// Layout
//-----------------------------------------------------------------------------

/**
 * @class RezLayout
 * @extends RezBlock
 * @abstract
 * @category Internal
 * @description Abstract base class for layout blocks.
 *
 * A layout is a special type of block that can contain other blocks as content.
 * Layouts have their own template that wraps the rendered content of their
 * child blocks.
 *
 * Subclasses must implement:
 * - `addContent(block)`: Add a content block
 * - `renderContents()`: Render all content blocks to HTML
 * - `bindAs()`: Return the binding name for this layout
 */
class RezLayout extends RezBlock {
  /**
   * @function constructor
   * @memberof RezLayout
   * @description Creates a new RezLayout.
   *
   * @param {string} blockType - The type of layout
   * @param {Object} source - The source element for this layout
   */
  constructor(blockType, source) {
    super(blockType, source);
  }

  /**
   * Adds a content block to this layout.
   *
   * @abstract
   * @param {RezBlock} block - The block to add
   * @throws {Error} Must be implemented by subclass
   */
  addContent(block) {
    throw new Error("Must implement addContent(block)");
  }

  /**
   * Renders all content blocks to HTML.
   *
   * @abstract
   * @returns {string} Rendered content HTML
   * @throws {Error} Must be implemented by subclass
   */
  renderContents() {
    throw new Error("Must implement renderContents()");
  }

  /**
   * Returns the binding name for this layout type.
   *
   * @abstract
   * @returns {string} The binding name
   * @throws {Error} Must be implemented by subclass
   */
  bindAs() {
    throw new Error("Must implement bindAs()");
  }

  /**
   * Renders this layout to HTML.
   *
   * Renders content first, then applies the layout's template with
   * the content and all bindings.
   *
   * @returns {string} Complete HTML for this layout
   */
  html() {
    const renderedContent = this.renderContents();
    const templateFn = this.getViewTemplate();
    const boundValues = this.bindValues();
    const boundBlocks = this.bindBlocks();
    return templateFn({
      content: renderedContent,
      ...this.parentBindings(),
      ...boundValues,
      ...boundBlocks,
    });
  }
}

//-----------------------------------------------------------------------------
// Single Layout
//-----------------------------------------------------------------------------

/**
 * @class RezSingleLayout
 * @extends RezLayout
 * @category Internal
 * @description A layout that holds exactly one content block.
 *
 * Use this layout when you need to wrap a single piece of content
 * with a layout template.
 */
class RezSingleLayout extends RezLayout {
  /** @type {RezBlock|null} */
  #content;

  /**
   * @function constructor
   * @memberof RezSingleLayout
   * @description Creates a new RezSingleLayout.
   *
   * @param {string} sourceName - The block type name
   * @param {Object} source - The source element for this layout
   */
  constructor(sourceName, source) {
    super(sourceName, source);
    this.#content = null;
  }

  /**
   * Returns the binding name for this layout.
   *
   * @returns {string} The block type as binding name
   */
  bindAs() {
    return this.blockType;
  }

  /**
   * Sets the single content block for this layout.
   *
   * @param {RezBlock} block - The content block
   */
  addContent(block) {
    block.parentBlock = this;
    this.#content = block;
  }

  /**
   * Renders the content block to HTML.
   *
   * @returns {string} Rendered content HTML
   */
  renderContents() {
    return this.#content.html();
  }

  /**
   * Creates a copy of this layout including its content.
   *
   * @returns {RezSingleLayout} A new layout with copied content
   */
  copy() {
    const copy = new RezSingleLayout(this.blockType, this.source);
    copy.parentBlock = this.parentBlock;
    copy.flipped = this.flipped;
    copy.params = this.params;
    if (this.#content) {
      copy.addContent(this.#content.copy());
    }
    return copy;
  }
}

window.Rez.RezSingleLayout = RezSingleLayout;

//-----------------------------------------------------------------------------
// Stack Layout
//-----------------------------------------------------------------------------

/**
 * @class RezStackLayout
 * @extends RezLayout
 * @category Internal
 * @description A layout that holds a list of content blocks rendered in sequence.
 *
 * Content blocks are rendered in order (or reversed order if `layout_reverse`
 * is set on the source) and joined with an optional separator.
 *
 * Source element attributes:
 * - `layout_reverse`: If true, new content is added to the front
 * - `layout_separator`: HTML string inserted between content blocks
 */
class RezStackLayout extends RezLayout {
  /** @type {RezBlock[]} */
  #contents;

  /**
   * @function constructor
   * @memberof RezStackLayout
   * @description Creates a new RezStackLayout.
   *
   * @param {string} sourceName - The block type name
   * @param {Object} source - The source element for this layout
   */
  constructor(sourceName, source) {
    super(sourceName, source);
    this.#contents = [];
  }

  /**
   * Returns the binding name for this layout.
   *
   * @returns {string} The block type as binding name
   */
  bindAs() {
    return this.blockType;
  }

  /**
   * Whether content should be added in reverse order.
   *
   * @type {boolean}
   */
  get reversed() {
    return this.source.layout_reverse;
  }

  /**
   * Adds a content block to the layout.
   *
   * If reversed, adds to the front; otherwise adds to the back.
   *
   * @param {RezBlock} block - The content block to add
   */
  addContent(block) {
    block.parentBlock = this;

    if (this.reversed) {
      this.#contents.unshift(block);
    } else {
      this.#contents.push(block);
    }
  }

  /**
   * Renders all content blocks to HTML.
   *
   * Blocks are joined with the layout_separator if specified.
   *
   * @returns {string} Rendered content HTML
   */
  renderContents() {
    let separator = "";
    if (this.source.layout_separator) {
      separator = this.source.layout_separator;
    }

    return this.#contents.map((block) => block.html()).join(separator);
  }

  /**
   * Creates a copy of this layout including all content blocks.
   *
   * @returns {RezStackLayout} A new layout with copied content
   */
  copy() {
    const copy = new RezStackLayout(this.blockType, this.source);
    copy.parentBlock = this.parentBlock;
    copy.flipped = this.flipped;
    copy.params = this.params;
    for (const block of this.#contents) {
      copy.addContent(block.copy());
    }
    return copy;
  }
};

window.Rez.RezStackLayout = RezStackLayout;

//-----------------------------------------------------------------------------
// Transformers
//-----------------------------------------------------------------------------

/**
 * @class RezTransformer
 * @abstract
 * @category Internal
 * @description Base class for DOM transformers.
 *
 * A transformer uses a CSS selector to find certain elements in the rendered
 * content and performs operations on them (typically adding event handlers
 * or modifying properties).
 *
 * Subclasses must implement `transformElement(elem, view)` to define the
 * transformation applied to each matching element.
 */
class RezTransformer {
  /** @type {string} */
  #selector;
  /** @type {string|null} */
  #eventName;
  /** @type {Object|null} */
  #receiver;

  /**
   * @function constructor
   * @memberof RezTransformer
   * @description Creates a new RezTransformer.
   *
   * @param {string} selector - CSS selector for elements to transform
   * @param {string|null} [eventName=null] - Event name for event-based transformers
   * @param {Object|null} [receiver=null] - Event receiver object
   */
  constructor(selector, eventName = null, receiver = null) {
    this.#selector = selector;
    this.#eventName = eventName;
    this.#receiver = receiver;
  }

  /**
   * The CSS selector used to find elements.
   * @type {string}
   */
  get selector() {
    return this.#selector;
  }

  /**
   * The event name for event-based transformers.
   * @type {string|null}
   */
  get eventName() {
    return this.#eventName;
  }

  /**
   * The receiver object for event handling.
   * @type {Object|null}
   */
  get receiver() {
    return this.#receiver;
  }

  /**
   * All DOM elements matching this transformer's selector.
   * @type {NodeList}
   */
  get elements() {
    return document.querySelectorAll(this.#selector);
  }

  /**
   * Transforms all matching elements in the document.
   *
   * @param {RezView} view - The view being transformed
   */
  transformElements(view) {
    this.elements.forEach((elem) => {
      this.transformElement(elem, view);
    });
  }

  /**
   * Transforms a single element.
   *
   * @abstract
   * @param {Element} elem - The DOM element to transform
   * @param {RezView} view - The view being transformed
   * @throws {Error} Must be implemented by subclass
   */
  transformElement(elem, view) {
    throw new Error("Transformers must implement transformElement(elem, view)!");
  }
}

//-----------------------------------------------------------------------------
// Event Transformers
//-----------------------------------------------------------------------------

/**
 * @class RezEventTransformer
 * @extends RezTransformer
 * @category Internal
 * @description A transformer that adds event listeners to matching elements.
 *
 * The receiver is expected to implement:
 * - `handleBrowserEvent(evt)`: Process the event and return a response object
 * - `dispatchResponse(response)`: Handle the response (e.g., scene changes)
 *
 * Response object keys:
 * - `scene`: Load a new scene by ID
 * - `card`: Play a card into the current scene
 * - `flash`: Update the flash message
 * - `render`: Trigger a view re-render
 * - `error`: Log an error message
 */
class RezEventTransformer extends RezTransformer {
  /**
   * @function constructor
   * @memberof RezEventTransformer
   * @description Creates a new RezEventTransformer.
   *
   * @param {string} selector - CSS selector for elements to transform
   * @param {string} event - Event name to listen for (e.g., "click", "submit")
   * @param {Object} receiver - Object that handles events
   */
  constructor(selector, event, receiver) {
    super(selector, event, receiver);
  }

  /**
   * Adds the event listener to an element.
   *
   * The listener prevents default behavior and routes the event through
   * the receiver's handleBrowserEvent and dispatchResponse methods.
   *
   * @param {Element} elem - The DOM element
   */
  addEventListener(elem) {
    const transformer = this;
    elem.addEventListener(this.eventName, function (evt) {
      evt.preventDefault();

      // A handler should return a RezEvent object
      const receiver = transformer.receiver;
      receiver.dispatchResponse(receiver.handleBrowserEvent(evt));
    });
  }

  /**
   * Transforms an element by adding an event listener.
   *
   * @param {Element} elem - The DOM element
   * @param {RezView} view - The view being transformed
   */
  transformElement(elem, view) {
    this.addEventListener(elem);
  }
}

//-----------------------------------------------------------------------------
// Block Transformer
//-----------------------------------------------------------------------------

/**
 * @class RezBlockTransformer
 * @extends RezTransformer
 * @category Internal
 * @description Transformer that links DOM elements to their corresponding card objects.
 *
 * Operates on `<div class="card" data-card="...">` elements, adding a
 * `rez_card` property that references the actual card object.
 */
class RezBlockTransformer extends RezTransformer {
  /**
   * @function constructor
   * @memberof RezBlockTransformer
   * @description Creates a new RezBlockTransformer.
   */
  constructor() {
    super("div.rez-card div[data-card]");
  }

  /**
   * Links the element to its card object.
   *
   * @param {Element} elem - The DOM element with data-card attribute
   * @param {RezView} view - The view being transformed
   */
  transformElement(elem, view) {
    const elem_id = elem.dataset.card;
    elem.rez_card = $(elem_id);
  }

}

window.Rez.RezBlockTransformer = RezBlockTransformer;

//-----------------------------------------------------------------------------
// Link Transformers
//-----------------------------------------------------------------------------

/**
 * @class RezEventLinkTransformer
 * @extends RezEventTransformer
 * @category Internal
 * @description Transformer for event links (`<a data-event="...">`) in cards.
 *
 * Adds click handlers to links within active or front-facing cards that
 * have a `data-event` attribute. When clicked, the event is routed through
 * the receiver's event handling system.
 */
class RezEventLinkTransformer extends RezEventTransformer {
  /**
   * @function constructor
   * @memberof RezEventLinkTransformer
   * @description Creates a new RezEventLinkTransformer.
   *
   * @param {Object} receiver - Object that handles click events
   */
  constructor(receiver) {
    super("div.rez-evented a[data-event], div.rez-active-card a[data-event]", "click", receiver);
  }
}

window.Rez.RezEventLinkTransformer = RezEventLinkTransformer;

//-----------------------------------------------------------------------------
// Button Transformer
//-----------------------------------------------------------------------------

/**
 * @class RezButtonTransformer
 * @extends RezEventTransformer
 * @category Internal
 * @description Transformer for event buttons (`<button data-event="...">`) in cards.
 *
 * Adds click handlers to buttons that have a `data-event` attribute and
 * are not marked as inactive. Only targets buttons within front-facing cards.
 */
class RezButtonTransformer extends RezEventTransformer {
  /**
   * @function constructor
   * @memberof RezButtonTransformer
   * @description Creates a new RezButtonTransformer.
   *
   * @param {Object} receiver - Object that handles click events
   */
  constructor(receiver) {
    super("div.rez-evented button[data-event]:not(.inactive)", "click", receiver);
  }
}

window.Rez.RezButtonTransformer = RezButtonTransformer;

//-----------------------------------------------------------------------------
// FormTransformer
//-----------------------------------------------------------------------------

/**
 * @class RezFormTransformer
 * @extends RezEventTransformer
 * @category Internal
 * @description Transformer for live forms (`<form rez-live>`) in cards.
 *
 * Adds submit handlers to forms with the `rez-live` attribute. Form
 * submissions are prevented and routed through the receiver instead.
 */
class RezFormTransformer extends RezEventTransformer {
  /**
   * @function constructor
   * @memberof RezFormTransformer
   * @description Creates a new RezFormTransformer.
   *
   * @param {Object} receiver - Object that handles form submissions
   */
  constructor(receiver) {
    super("div.rez-evented form[rez-live]", "submit", receiver);
  }
}

window.Rez.RezFormTransformer = RezFormTransformer;

//-----------------------------------------------------------------------------
// InputTransformer
//-----------------------------------------------------------------------------

/**
 * @class RezInputTransformer
 * @extends RezEventTransformer
 * @category Internal
 * @description Transformer for live input elements in cards.
 *
 * Adds input event handlers to form elements (input, select, textarea)
 * with the `rez-live` attribute for real-time value updates.
 */
class RezInputTransformer extends RezEventTransformer {
  /**
   * @function constructor
   * @memberof RezInputTransformer
   * @description Creates a new RezInputTransformer.
   *
   * @param {Object} receiver - Object that handles input events
   */
  constructor(receiver) {
    super("div.rez-evented input[rez-live], div.rez-evented select[rez-live], div.rez-evented textarea[rez-live]", "input", receiver);
  }
}

window.Rez.RezInputTransformer = RezInputTransformer;

//-----------------------------------------------------------------------------
// EnterKeyTransformer
//-----------------------------------------------------------------------------

/**
 * @class RezEnterKeyTransformer
 * @extends RezEventTransformer
 * @category Internal
 * @description Transformer that handles Enter key presses in form inputs.
 *
 * Listens for keydown events on text-like inputs within `rez-live` forms.
 * When Enter is pressed, synthesizes a form submit event and routes it
 * through the receiver.
 *
 * Supported input types: text, email, password, search, url, tel, number,
 * and inputs without a type attribute.
 */
class RezEnterKeyTransformer extends RezEventTransformer {
  /**
   * @function constructor
   * @memberof RezEnterKeyTransformer
   * @description Creates a new RezEnterKeyTransformer.
   *
   * @param {Object} receiver - Object that handles submit events
   */
  constructor(receiver) {
    super("div.rez-evented form[rez-live] input[type='text'], div.rez-evented form[rez-live] input[type='email'], div.rez-evented form[rez-live] input[type='password'], div.rez-evented form[rez-live] input[type='search'], div.rez-evented form[rez-live] input[type='url'], div.rez-evented form[rez-live] input[type='tel'], div.rez-evented form[rez-live] input[type='number'], div.rez-evented form[rez-live] input:not([type])", "keydown", receiver);
  }

  /**
   * Adds a keydown listener that synthesizes submit events on Enter.
   *
   * @param {Element} elem - The input element
   */
  addEventListener(elem) {
    const transformer = this;
    elem.addEventListener(this.eventName, function (evt) {
      if(evt.key === "Enter") {
        evt.preventDefault();

        // Find the parent form
        const form = evt.target.closest("form[rez-live]");

        if(form) {
          const formName = form.getAttribute("name");

          if(!formName) {
            console.error("RezEnterKeyTransformer: Form has no name attribute!");
            return;
          }

          // Create a synthetic submit event with correct target and type
          const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
          Object.defineProperty(submitEvent, 'target', { value: form, enumerable: true });
          Object.defineProperty(submitEvent, 'type', { value: 'submit', enumerable: true });

          // Use the receiver's handleBrowserEvent method (which routes to handleBrowserSubmitEvent)
          transformer.receiver.dispatchResponse(
            transformer.receiver.handleBrowserEvent(submitEvent)
          );
        } else {
          console.error("RezEnterKeyTransformer: No rez-live form found!");
        }
      }
    });
  }
}

window.Rez.RezEnterKeyTransformer = RezEnterKeyTransformer;

//-----------------------------------------------------------------------------
// BindingTransformer
//-----------------------------------------------------------------------------

/**
 * @class RezBindingTransformer
 * @extends RezTransformer
 * @category Internal
 * @description Transformer that creates two-way data bindings between form elements and game state.
 *
 * Operates on form elements (input, select, textarea) with the `rez-bind`
 * attribute. The attribute value specifies the binding target in the format
 * `element_id.attribute_name`.
 *
 * Special binding IDs:
 * - `game`: Binds to the $game object
 * - `scene`: Binds to the current scene
 * - `card`: Binds to the nearest card in the DOM hierarchy
 *
 * Supports input types: text, textarea, checkbox, radio, select.
 *
 * @example
 * <!-- Bind to game.player_name -->
 * <input type="text" rez-bind="game.player_name">
 *
 * <!-- Bind to current scene's difficulty -->
 * <select rez-bind="scene.difficulty">
 */
class RezBindingTransformer extends RezTransformer {
  /**
   * @function constructor
   * @memberof RezBindingTransformer
   * @description Creates a new RezBindingTransformer.
   *
   * @param {Object} receiver - Event receiver (unused, for API compatibility)
   */
  constructor(receiver) {
    super("div.rez-evented input[rez-bind], div.rez-evented select[rez-bind], div.rez-evented textarea[rez-bind]");
  }

  /**
   * Parses a binding expression into element ID and attribute name.
   *
   * @param {string} binding_expr - Binding expression (e.g., "game.score")
   * @returns {Array<string>} Array of [element_id, attribute_name]
   * @throws {string} If binding expression is invalid
   */
  decodeBinding(binding_expr) {
    const [binding_id, binding_attr] = binding_expr.split(".");
    if (
      typeof binding_id === "undefined" ||
      typeof binding_attr === "undefined"
    ) {
      throw `Unable to parse binding: ${binding_expr}`;
    }

    return [binding_id, binding_attr];
  }

  /**
   * Resolves a binding ID to its corresponding game element.
   *
   * @param {Element} input - The form input element
   * @param {string} binding_id - The binding target ID
   * @returns {Object} The resolved game element
   * @throws {Error} If card binding used outside a card context
   */
  getBoundElem(input, binding_id) {
    if(binding_id === "game") {
      return $game;
    } else if(binding_id === "scene") {
      return $game.current_scene;
    } else if(binding_id === "card") {
      const card_el = input.closest("div.card");
      if(!card_el) {
        throw new Error(`Unable to find nearest @card to input`);
      }
      return card_el.rez_card;
    } else {
      return $(binding_id);
    };
  }

  /**
   * Gets the current value of a bound attribute.
   *
   * @param {Element} input - The form input element
   * @param {string} boundRezElementId - The binding target ID
   * @param {string} boundAttrName - The attribute name
   * @returns {*} The attribute value
   * @throws {Error} If element not found
   */
  getBoundValue(input, boundRezElementId, boundAttrName) {
    const elem = this.getBoundElem(input, boundRezElementId);
    if(elem === undefined) {
      throw new Error(`Failed to find game element for attribute binding: ${boundRezElementId}`);
    }
    return elem.getAttribute(boundAttrName)
  }

  /**
   * Sets the value of a bound attribute.
   *
   * @param {Element} input - The form input element
   * @param {string} boundRezElementId - The binding target ID
   * @param {string} boundAttrName - The attribute name
   * @param {*} value - The new value
   * @throws {Error} If element not found
   */
  setBoundValue(input, boundRezElementId, boundAttrName, value) {
    const elem = this.getBoundElem(input, boundRezElementId);
    if(typeof(elem) === "undefined") {
      throw new Error(`Failed to find game element for attribute binding: ${boundRezElementId}`);
    }
    elem.setAttribute(boundAttrName, value);
  }

  /**
   * Sets up two-way binding for a text input or textarea.
   *
   * @param {RezView} view - The view for binding registration
   * @param {Element} input - The input element
   * @param {string} binding_id - The binding target ID
   * @param {string} binding_attr - The attribute name
   */
  transformTextInput(view, input, binding_id, binding_attr) {
    const transformer = this;

    view.registerBinding(binding_id, binding_attr, function (value) {
      input.value = value;
    });

    input.value = this.getBoundValue(input, binding_id, binding_attr);
    input.addEventListener("input", function (evt) {
      transformer.setBoundValue(input, binding_id, binding_attr, evt.target.value);
    });
  }

  /**
   * Sets up two-way binding for a checkbox input.
   *
   * @param {RezView} view - The view for binding registration
   * @param {Element} input - The checkbox element
   * @param {string} binding_id - The binding target ID
   * @param {string} binding_attr - The attribute name
   */
  transformCheckboxInput(view, input, binding_id, binding_attr) {
    const transformer = this;

    view.registerBinding(binding_id, binding_attr, function (value) {
      input.checked = value;
    });
    input.checked = this.getBoundValue(input, binding_id, binding_attr);
    input.addEventListener("input", function (evt) {
      transformer.setBoundValue(input, binding_id, binding_attr, evt.target.checked);
    });
  }

  /**
   * Sets the selected radio button in a group.
   *
   * @param {string} group_name - The radio group name
   * @param {string} value - The value to select
   */
  setRadioGroupValue(group_name, value) {
    const radios = document.getElementsByName(group_name);
    for (let radio of radios) {
      if (radio.value == value) {
        radio.checked = true;
      }
    }
  }

  /**
   * Adds change tracking to all radios in a group.
   *
   * @param {string} group_name - The radio group name
   * @param {Function} callback - Callback for input events
   */
  trackRadioGroupChange(group_name, callback) {
    const radios = document.getElementsByName(group_name);
    for (let radio of radios) {
      radio.addEventListener("input", callback);
    }
  }

  /**
   * Sets up two-way binding for a radio button group.
   *
   * Only the first radio in a group needs to register the binding.
   *
   * @param {RezView} view - The view for binding registration
   * @param {Element} input - A radio button in the group
   * @param {string} binding_id - The binding target ID
   * @param {string} binding_attr - The attribute name
   */
  transformRadioInput(view, input, binding_id, binding_attr) {
    const transformer = this;
    if (!view.hasBinding(binding_id, binding_attr)) {
      // We only need to bind the first radio in the group
      view.registerBinding(binding_id, binding_attr, function (value) {
        transformer.setRadioGroupValue(input.name, value);
      });
    }

    this.setRadioGroupValue(input.name, this.getBoundValue(input, binding_id, binding_attr));

    this.trackRadioGroupChange(input.name, function (evt) {
      transformer.setBoundValue(input, binding_id, binding_attr, evt.target.value);
    });
  }

  /**
   * Sets up two-way binding for a select element.
   *
   * @param {RezView} view - The view for binding registration
   * @param {Element} select - The select element
   * @param {string} binding_id - The binding target ID
   * @param {string} binding_attr - The attribute name
   */
  transformSelect(view, select, binding_id, binding_attr) {
    const transformer = this;

    view.registerBinding(binding_id, binding_attr, function (value) {
      select.value = value;
    });
    select.value = this.getBoundValue(select, binding_id, binding_attr);
    select.addEventListener("input", function (evt) {
      transformer.setBoundValue(select, binding_id, binding_attr, evt.target.value);
    });
  }

  /**
   * Transforms a form element by setting up two-way data binding.
   *
   * Delegates to type-specific methods based on input type.
   *
   * @param {Element} input - The form element to transform
   * @param {RezView} view - The view for binding registration
   * @throws {Error} If input type is not supported
   */
  transformElement(input, view) {
    const [binding_id, binding_attr] = this.decodeBinding(
      input.getAttribute("rez-bind")
    );

    if (input.type === "text" || input.tagName.toLowerCase() === "textarea") {
      this.transformTextInput(view, input, binding_id, binding_attr);
    } else if (input.type === "checkbox") {
      this.transformCheckboxInput(view, input, binding_id, binding_attr);
    } else if (input.type === "radio") {
      this.transformRadioInput(view, input, binding_id, binding_attr);
    } else if (
      input.type === "select-one" ||
      input.type === "select-multiple"
    ) {
      this.transformSelect(view, input, binding_id, binding_attr);
    } else {
      throw new Error(`Unsupported input type: ${input.type}`);
    }
  }
}

window.Rez.RezBindingTransformer = RezBindingTransformer;

//-----------------------------------------------------------------------------
// View
//-----------------------------------------------------------------------------

/**
 * @class RezView
 * @category Internal
 * @description The main view controller that manages rendering and DOM transformations.
 *
 * RezView is responsible for:
 * - Managing the layout hierarchy and content blocks
 * - Rendering HTML into the container element
 * - Applying transformers to set up event handlers and bindings
 * - Tracking two-way data bindings between form elements and game state
 *
 * The view uses a layout stack to support nested layouts during complex
 * rendering scenarios. Bindings are cleared and re-established on each
 * render cycle.
 *
 * @example
 * // Create a view with a stack layout
 * const layout = new RezStackLayout("scene", sceneElement);
 * const view = new RezView("game-container", eventReceiver, layout);
 *
 * // Add content and render
 * view.addLayoutContent(new RezBlock("card", cardElement));
 * view.update();
 */
class RezView {
  /** @type {Element} */
  #container;
  /** @type {RezLayout} */
  #layout;
  /** @type {RezLayout[]} */
  #layoutStack;
  /** @type {Map<string, Function>} */
  #bindings;
  /** @type {Object} */
  #receiver;
  /** @type {RezTransformer[]} */
  #transformers;

  /**
   * @function constructor
   * @memberof RezView
   * @description Creates a new RezView.
   * @param {string} container_id - ID of the DOM container element
   * @param {Object} receiver - Event receiver for handling user interactions
   * @param {RezLayout} layout - The root layout for the view
   * @param {RezTransformer[]} [transformers] - Custom transformers (uses defaults if not provided)
   * @throws {Error} If container element not found
   */
  constructor(container_id, receiver, layout, transformers) {
    const container = document.getElementById(container_id);
    if(!container) {
      throw Error(`Cannot get container |${container_id}|`);
    }

    this.#container = container;
    this.#layout = layout;
    this.#layoutStack = [];
    this.#bindings = new Map();
    this.#receiver = receiver;
    this.#transformers = transformers ?? this.defaultTransformers();
  }

  /**
   * The DOM container element for this view.
   * @type {Element}
   */
  get container() {
    return this.#container;
  }

  /**
   * The current root layout.
   * @type {RezLayout}
   */
  get layout() {
    return this.#layout;
  }

  set layout(layout) {
    this.#layout = layout;
  }

  /**
   * The stack of pushed layouts.
   * @type {RezLayout[]}
   */
  get layoutStack() {
    return this.#layoutStack;
  }

  /**
   * Pushes a new layout onto the stack, making it the current layout.
   *
   * Use this when entering a nested layout context. Pop when exiting.
   *
   * @param {RezLayout} layout - The layout to push
   */
  pushLayout(layout) {
    this.layoutStack.push(this.layout);
    this.layout = layout;
  }

  /**
   * Pops the current layout and restores the previous one.
   */
  popLayout() {
    this.layout = this.layoutStack.pop();
  }

  /**
   * Adds content to the current layout.
   *
   * @param {RezBlock} content - The content block to add
   */
  addLayoutContent(content) {
    this.layout.addContent(content);
  }

  /**
   * Map of registered bindings (keyed by "id.attr").
   * @type {Map<string, Function>}
   */
  get bindings() {
    return this.#bindings;
  }

  /**
   * The event receiver for this view.
   * @type {Object}
   */
  get receiver() {
    return this.#receiver;
  }

  /**
   * The transformers applied after each render.
   * @type {RezTransformer[]}
   */
  get transformers() {
    return this.#transformers;
  }

  /**
   * Renders the layout HTML into the container.
   *
   * Note: Event listeners added by transformers are automatically cleaned up
   * when innerHTML is replaced - no explicit cleanup needed. The DOM elements
   * holding the listeners are destroyed, allowing garbage collection.
   */
  render() {
    const html = this.layout.html();
    this.container.innerHTML = html;
  }

  /**
   * Creates the default set of transformers.
   *
   * Default transformers handle:
   * - Event links (`<a data-event>`)
   * - Card block references
   * - Event buttons
   * - Form submissions
   * - Data bindings (`rez-bind`)
   * - Live inputs (`rez-live`)
   * - Enter key handling
   *
   * @returns {RezTransformer[]} Array of default transformers
   */
  defaultTransformers() {
    return [
      new RezEventLinkTransformer(this.receiver),
      new RezBlockTransformer(),
      new RezButtonTransformer(this.receiver),
      new RezFormTransformer(this.receiver),
      new RezBindingTransformer(this.receiver),
      new RezInputTransformer(this.receiver),
      new RezEnterKeyTransformer(this.receiver)
    ];
  }

  /**
   * Applies all transformers to the rendered DOM.
   */
  transform() {
    this.transformers.forEach((transformer) =>
      transformer.transformElements(this)
    );
  }

  /**
   * Checks if a binding is registered for a given element and attribute.
   *
   * @param {string} binding_id - The element ID
   * @param {string} binding_attr - The attribute name
   * @returns {boolean} True if binding exists
   */
  hasBinding(binding_id, binding_attr) {
    return this.bindings.has(`${binding_id}.${binding_attr}`);
  }

  /**
   * Registers a callback for updating a bound control when data changes.
   *
   * @param {string} binding_id - The element ID
   * @param {string} binding_attr - The attribute name
   * @param {Function} callback - Function called with new value when data changes
   */
  registerBinding(binding_id, binding_attr, callback) {
    this.bindings.set(`${binding_id}.${binding_attr}`, callback);
  }

  /**
   * Updates bound form controls when the underlying data changes.
   *
   * Called by game elements when their attributes change to keep
   * the UI in sync.
   *
   * @param {string} binding_id - The element ID
   * @param {string} binding_attr - The attribute name
   * @param {*} value - The new value
   */
  updateBoundControls(binding_id, binding_attr, value) {
    const callback = this.bindings.get(`${binding_id}.${binding_attr}`);
    if (typeof callback === "function") {
      callback(value);
    }
  }

  /**
   * Clears all registered bindings.
   *
   * Called at the start of each render cycle since bindings are
   * re-established by transformers.
   */
  clearBindings() {
    this.bindings.clear();
  }

  /**
   * Performs a complete view update: clear bindings, render, and transform.
   *
   * This is the main method to call when the view needs to refresh.
   */
  update() {
    this.clearBindings();
    this.render();
    this.transform();
  }

  /**
   * Creates a copy of this view with copied layouts.
   *
   * Bindings are not copied as they are cleared on each render.
   *
   * @returns {RezView} A new view with copied state
   */
  copy() {
    const copy = new RezView(
      this.container.id,
      this.receiver,
      this.layout.copy(),
      this.transformers
    );
    for (const layout of this.#layoutStack) {
      copy.#layoutStack.push(layout.copy());
    }
    // bindings are cleared on each render, no need to copy
    return copy;
  }
}

window.Rez.RezView = RezView;