// Cat.js

// Immediately Invoked Function Expression (IIFE) to encapsulate the compiler
(function() {
  'use strict';

  /**
   * Internal module for tracking loaded files.
   */
  const Registry = (function() {
    const loadedFiles = new Set();

    return {
      isFileLoaded: (path) => loadedFiles.has(path),
      markFileAsLoaded: (path) => loadedFiles.add(path),
    };
  })();

  /**
   * Compiler module for generating self-contained component classes.
   */
  const Compiler = (function() {
    /**
     * Compiles the component's template and script into a component class definition.
     * @param {string} name - The name of the component (e.g., 'my-component').
     * @param {string} templateContent - The inner HTML of the component's <template>.
     * @param {string} scriptContent - The content of the component's <script>.
     */
    function compileComponent(name, templateContent, scriptContent) {
      // Create a template element and pre-clone its content
      const templateElement = document.createElement('template');
      templateElement.innerHTML = templateContent;
      const templateContentClone = templateElement.content;

      // Process the template to find bindings
      const bindings = []; // Array to hold binding info
      const propertyNames = new Set();

      // Process nodes recursively
      const processNode = (node, path) => {
        if (node.nodeType === Node.TEXT_NODE) {
          const text = node.textContent;
          const parts = text.split(/(\{(?:[^}]+)\})/);
          let hasBinding = false;

          // Collect variable names inside { }
          for (let i = 0; i < parts.length; i++) {
            const part = parts[i];
            const match = part.match(/^\{([^}]+)\}$/);
            if (match) {
              const varName = match[1].trim();
              propertyNames.add(varName);
              hasBinding = true;
            }
          }

          if (hasBinding) {
            bindings.push({
              type: 'text',
              nodePath: path.slice(),
              varNames: getVariableNamesFromParts(parts),
              parts: parts,
            });
          }
        } else if (node.nodeType === Node.ELEMENT_NODE) {
          if (node.nodeName.toLowerCase() === 'style') {
            // Do not process the content of style tags
            return;
          }

          // Process attributes
          for (let attr of Array.from(node.attributes)) {
            const value = attr.value;
            const parts = value.split(/(\{(?:[^}]+)\})/);
            let hasBinding = false;

            // Collect variable names inside { }
            for (let i = 0; i < parts.length; i++) {
              const part = parts[i];
              const match = part.match(/^\{([^}]+)\}$/);
              if (match) {
                const varName = match[1].trim();
                propertyNames.add(varName);
                hasBinding = true;
              }
            }

            if (hasBinding) {
              bindings.push({
                type: 'attribute',
                nodePath: path.slice(),
                attributeName: attr.name,
                varNames: getVariableNamesFromParts(parts),
                parts: parts,
              });
            }
          }

          // Process child nodes recursively
          node.childNodes.forEach((childNode, index) => {
            path.push(index);
            processNode(childNode, path);
            path.pop();
          });
        }
      };

      // Helper function to extract variable names from parts
      const getVariableNamesFromParts = (parts) => {
        const varNames = [];
        for (let i = 0; i < parts.length; i++) {
          const part = parts[i];
          const match = part.match(/^\{([^}]+)\}$/);
          if (match) {
            varNames.push(match[1].trim());
          }
        }
        return varNames;
      };

      // Start processing from templateElement.content
      templateElement.content.childNodes.forEach((node, index) => {
        processNode(node, [index]);
      });

      // Precompute propertyNames
      const properties = Array.from(propertyNames);

      // Generate the ComponentClass
      class ComponentClass extends HTMLElement {
        constructor() {
          super();
          const shadowRoot = this.attachShadow({ mode: 'open' });

          // Clone the template content into the shadowRoot
          shadowRoot.appendChild(ComponentClass.templateContent.cloneNode(true));

          // Initialize properties
          this._initProperties();

          // Setup bindings
          this._setupBindings(shadowRoot);
    
          // Initialize event listeners array
          this._eventListeners = [];
    
          // Execute user script in the context of 'this' safely
          if (ComponentClass.scriptFunction) {
            try {
              ComponentClass.scriptFunction.call(this);
            } catch (error) {
              console.error(`Error executing script in component ${name}: ${error.message}`);
            }
          }
        }

        _initProperties() {
          if (!this._propertyValues) {
            this._propertyValues = {};
          }
          // Initialize property values
          ComponentClass.properties.forEach(varName => {
            this._propertyValues[varName] = '';
          });
        }

        _setupBindings(shadowRoot) {
          this._bindingsMap = {};
          this._bindings = [];

          // Setup per-instance bindings
          ComponentClass.bindings.forEach(binding => {
            const node = this._getNodeByPath(shadowRoot, binding.nodePath);

            // Create a copy of the binding for this instance
            const instanceBinding = Object.assign({}, binding);
            instanceBinding.node = node;

            // For each varName in the binding, add the binding to _bindingsMap
            binding.varNames.forEach(varName => {
              if (!this._bindingsMap[varName]) {
                this._bindingsMap[varName] = [];
              }
              this._bindingsMap[varName].push(instanceBinding);
            });

            this._bindings.push(instanceBinding);
          });
        }

        _getNodeByPath(root, path) {
          let current = root;
          for (let i = 0; i < path.length; i++) {
            current = current.childNodes[path[i]];
          }
          return current;
        }

        _updateProperty(varName) {
          const bindings = this._bindingsMap[varName];
          if (bindings) {
            bindings.forEach(binding => {
              if (binding.type === 'text') {
                const newText = this._computeBindingText(binding);
                binding.node.textContent = newText;
              } else if (binding.type === 'attribute') {
                const newValue = this._computeBindingText(binding);
                binding.node.setAttribute(binding.attributeName, newValue);
              }
            });
          }
        }

        _computeBindingText(binding) {
          const computedParts = binding.parts.map(part => {
            const match = part.match(/^\{([^}]+)\}$/);
            if (match) {
              const varName = match[1].trim();
              return this[varName];
            } else {
              return part;
            }
          });
          return computedParts.join('');
        }

        connectedCallback() {
          // Any additional initialization can be done here
          if (typeof this.onConnected === 'function') {
            this.onConnected();
          }
        }

        disconnectedCallback() {
          // Clean up event listeners
          this._eventListeners.forEach(({ target, type, listener, options }) => {
            target.removeEventListener(type, listener, options);
          });
          this._eventListeners = [];
    
          // Any additional cleanup
          if (typeof this.onDisconnected === 'function') {
            this.onDisconnected();
          }
        }

        // Method to add event listeners and track them
        _addEventListener(target, type, listener, options) {
          target.addEventListener(type, listener, options);
          this._eventListeners.push({ target, type, listener, options });
        }
      }

      // Attach static properties to the class
      ComponentClass.templateContent = templateContentClone;
      ComponentClass.bindings = bindings;
      ComponentClass.properties = properties;

      // Define properties on the prototype
      ComponentClass.properties.forEach(varName => {
        Object.defineProperty(ComponentClass.prototype, varName, {
          get: function() {
            return this._propertyValues[varName];
          },
          set: function(newValue) {
            if (this._propertyValues[varName] !== newValue) {
              this._propertyValues[varName] = newValue;
              this._updateProperty(varName);
            }
          },
        });
      });

      // Prepare and execute the scriptFunction once per component
      ComponentClass.scriptFunction = null;
      if (scriptContent.trim()) {
        try {
          // Wrap the script content in a function and execute it in the component's context
          ComponentClass.scriptFunction = new Function(scriptContent);
        } catch (error) {
          console.error(`Error compiling script in component ${name}: ${error.message}`);
        }
      }

      // Register the component
      customElements.define(name, ComponentClass);
    }

    return {
      compileComponent,
    };
  })();

  /**
   * Internal module for loading and parsing HTML files.
   */
  const Loader = (function() {
    /**
     * Loads an HTML file from the given path.
     * @param {string} path - The path to the HTML file (without .html extension).
     * @returns {Promise<string>} - A promise that resolves to the content of the HTML file.
     */
    async function loadFile(path) {
      const fullPath = `${path}.html`;

      try {
        const response = await fetch(fullPath);
        if (!response.ok) {
          throw new Error(`Failed to load: ${fullPath} (Status: ${response.status})`);
        }
        const content = await response.text();
        return content;
      } catch (error) {
        console.error(error.message);
        throw error;
      }
    }

    /**
     * Parses an HTML string into a Document object.
     * @param {string} htmlString - The HTML string to parse.
     * @returns {Document} - The parsed Document object.
     */
    function parseHTML(htmlString) {
      const parser = new DOMParser();
      const doc = parser.parseFromString(htmlString, 'text/html');
      return doc;
    }

    return {
      loadFile,
      parseHTML,
    };
  })();

  /**
   * Global load function.
   * @param {string} path - The path to the HTML file (without .html extension).
   * @returns {Promise<void>}
   */
  window.load = async function(path) {
    console.log(`Loading: ${path}`);

    if (Registry.isFileLoaded(path)) {
      console.log(`Already loaded: ${path}`);
      return;
    }

    try {
      const responseText = await Loader.loadFile(path);
      const doc = Loader.parseHTML(responseText);

      Registry.markFileAsLoaded(path);

      // Extract the <template> with the 'name' attribute
      const template = doc.querySelector('template[name]');
      if (!template) {
        console.error(`No <template name="..."> found in ${path}.html`);
        return;
      }

      const componentName = template.getAttribute('name');
      const templateContent = template.innerHTML;

      // Extract the <script> content
      const script = doc.querySelector('script');
      const scriptContent = script ? script.textContent : '';

      // Use compiler to generate and define the component
      Compiler.compileComponent(componentName, templateContent, scriptContent);

      console.log(`Component registered: ${componentName}`);
    } catch (error) {
      console.error(`Error loading component from ${path}: ${error.message}`);
    }
  };
})();

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Cat.js Debugging Sample</title>
    <script src="cat.js"></script>
    <script>
      load('app/comp/counter');
    </script>
</head>
<body>
    <my-counter></my-counter>
    <my-counter></my-counter>
</body>
</html>

<!-- app/comp/counter.html -->
<template name="my-counter">
    <style>
        .on {
            background-color: blue;
        }
        .off {
            background-color: aliceblue;
        }
        .btn {
            padding: 20px;
        }
    </style>

    <button class="{status} btn">Count: {count}</button>
    <user-name></user-name>
</template>

<script>

    load('app/comp/name')

    this.count = 0;
    this.status = 'on';

    const button = this.shadowRoot.querySelector('button');
    const component = this; // To maintain correct context inside functions

    function updateCount() {
        component.count++;
        component.status = component.status === 'on' ? 'off' : 'on';
    }

    this._addEventListener(button, 'click', updateCount);
</script>

----

Loading: app/comp/counter
2cat.js:342 Loading: app/comp/name
cat.js:372 Component registered: my-counter
cat.js:372 Component registered: user-name
cat.js:374 Error loading component from app/comp/name: Failed to execute 'define' on 'CustomElementRegistry': the name "user-name" has already been used with this registry

---