/**
 * What.js - Client-side framework for What
 * Provides HTML injection and interactivity without writing JavaScript
 * Created by Tedigo
 *
 * HTML Injection:
 * - w-get="url"     : Fetch HTML via GET and inject into target
 * - w-post="url"    : Fetch HTML via POST and inject into target
 * - w-target="sel"  : CSS selector for target element
 * - w-swap="mode"   : replace (default), prepend, append, before, after
 * - w-params="{}"   : JSON object of params to send
 * - w-include="sel" : Include form values from selector
 *
 * Actions & Triggers:
 * - w-action: Action to perform (create, update, delete, navigate, toggle, remove)
 * - w-trigger: When to act. For w-action: click, change. For w-get/w-post:
 *              load, revealed, poll <interval>, click — comma-combinable,
 *              e.g. w-trigger="load, poll 30s"
 * - w-confirm: Confirmation message before action (also honored on boosted links)
 * - w-loading: CSS class to add during loading
 * - w-boost: Enable link/form boosting (SPA-like navigation)
 *
 * Clipboard & Theme:
 * - w-clipboard="text"     : Copy literal text on click (w-copied class feedback)
 * - w-clipboard-from="sel" : Copy from an element (anchor → href, input → value, else text)
 * - w-copied-label="text"  : Temporary button label while copied
 * - w-theme-toggle         : Toggle dark/light class on <html>, persisted in localStorage
 */

(function() {
  'use strict';

  // Configuration
  const CONFIG = {
    formSelector: 'form[w-boost], form[action^="/w-action"]',
    cacheTimeout: 300000, // 5 minutes
    loadingClass: 'w-loading',
    activeClass: 'active'
  };

  // Simple page cache
  const pageCache = new Map();

  // Trigger lifecycle state: elements whose load/revealed/poll trigger has
  // been armed (WeakSet — entries vanish with their element), and the active
  // timers/observers that must be cancelled once their element leaves the DOM.
  const wArmedTriggers = new WeakSet();
  const wFetchLifecycles = [];

  // Debug levels: off, error, warn, info, verbose
  // Read from <meta name="what-debug"> (injected by server in dev mode)
  const _debugMeta = document.querySelector('meta[name="what-debug"]');
  const DEBUG_LEVEL = _debugMeta ? _debugMeta.getAttribute('content') : 'off';
  let DEBUG = DEBUG_LEVEL !== 'off';
  const _levels = { off: -1, error: 0, warn: 1, info: 2, verbose: 3, debug: 3 };
  const _currentLevel = _levels[DEBUG_LEVEL] !== undefined ? _levels[DEBUG_LEVEL] : -1;

  /**
   * Debug log with level support.
   * Usage: debug('info', 'message') or debug('message') for backward compat.
   */
  function debug(...args) {
    if (!DEBUG) return;
    let level = 'info';
    if (args.length > 1 && typeof args[0] === 'string' && _levels[args[0]] !== undefined) {
      level = args.shift();
    }
    if ((_levels[level] || 0) <= _currentLevel) {
      const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log';
      console[method]('[What]', ...args);
    }
  }

  /**
   * Get CSRF token from meta tag
   */
  function getCsrfToken() {
    const meta = document.querySelector('meta[name="csrf-token"]');
    return meta ? meta.getAttribute('content') : null;
  }

  /**
   * Add CSRF token header to a headers object (for POST requests)
   */
  function addCsrfHeader(headers) {
    const token = getCsrfToken();
    if (token) {
      headers['X-CSRF-Token'] = token;
    }
    return headers;
  }

  // ================================
  // Core Functions
  // ================================

  /**
   * Initialize What.js
   */
  function init() {
    // Intercept link clicks for SPA navigation
    document.addEventListener('click', handleLinkClick);

    // Intercept form submissions
    document.addEventListener('submit', handleFormSubmit);

    // Handle w-trigger elements
    document.addEventListener('click', handleTriggerClick);
    document.addEventListener('change', handleTriggerChange);

    // Handle w-get/w-post partial fetch elements
    document.addEventListener('click', handlePartialFetch);

    // Handle w-set declarative state mutations
    document.addEventListener('click', handleWSet);
    document.addEventListener('input', handleWSetInput);

    // Handle declarative clipboard copy and theme toggle
    document.addEventListener('click', handleClipboardClick);
    document.addEventListener('click', handleThemeToggle);

    // Handle modal open/close (delegated — survives SPA swaps without rebinding)
    document.addEventListener('click', handleModalClick);

    // Handle back/forward browser navigation. Scroll restoration is manual:
    // the browser's automatic restore fires before the swapped content
    // exists, so we save/restore positions in the history state ourselves.
    if ('scrollRestoration' in history) {
      history.scrollRestoration = 'manual';
    }
    window.addEventListener('popstate', handlePopState);

    // Initialize any w-* elements on page load
    initializeElements();

    // Initialize client-side form validation
    initFormValidation();

    // Connect to wired state WebSocket if page uses wired bindings
    initWire();

    console.log('[What] Initialized');
  }

  /**
   * Initialize elements with w-* attributes
   */
  function initializeElements() {
    // Auto-focus first input with w-autofocus
    const autofocus = document.querySelector('[w-autofocus]');
    if (autofocus) autofocus.focus();

    // Cancel timers/observers whose element left the DOM in the last swap,
    // then arm load/revealed/poll triggers on any new w-get/w-post elements.
    sweepFetchLifecycles();
    initFetchTriggers();
  }

  /**
   * Modal open/close via document-level delegation. Registered ONCE in
   * init(): per-element listeners in initializeElements() re-attached on
   * every SPA swap, so [w-persist] elements accumulated duplicate handlers.
   */
  function handleModalClick(event) {
    const trigger = event.target.closest('[w-modal-trigger]');
    if (trigger) {
      event.preventDefault();
      const modal = document.getElementById(trigger.getAttribute('w-modal-trigger'));
      if (modal) toggleModal(modal, true);
      return;
    }
    const close = event.target.closest('[w-modal-close]');
    if (close) {
      event.preventDefault();
      const modal = close.closest('.modal-backdrop') || close.closest('.drawer-backdrop');
      if (modal) toggleModal(modal, false);
    }
  }

  // ================================
  // Clipboard & Theme (declarative)
  // ================================

  // Feedback-revert timers per element (rapid re-clicks reset cleanly)
  const wCopiedTimers = new WeakMap();

  /**
   * Copy to clipboard via w-clipboard (literal text) or w-clipboard-from
   * (CSS selector: anchor → absolute href, input → value, else textContent).
   * Feedback: w-copied class (+ optional w-copied-label swap) for 1.5s.
   */
  async function handleClipboardClick(event) {
    const el = event.target.closest('[w-clipboard], [w-clipboard-from]');
    if (!el) return;
    event.preventDefault();

    let text = el.getAttribute('w-clipboard');
    if (text === null) {
      const selector = el.getAttribute('w-clipboard-from');
      const source = document.querySelector(selector);
      if (!source) {
        debug('warn', 'w-clipboard-from target not found:', selector);
        return;
      }
      if (source.tagName === 'A') text = source.href;
      else if (source.tagName === 'INPUT' || source.tagName === 'TEXTAREA' || source.tagName === 'SELECT') text = source.value;
      else text = source.textContent.trim();
    }

    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(text);
      } else {
        // Fallback for non-secure contexts (plain http in dev)
        const textarea = document.createElement('textarea');
        textarea.value = text;
        textarea.style.position = 'fixed';
        textarea.style.opacity = '0';
        document.body.appendChild(textarea);
        textarea.select();
        document.execCommand('copy');
        textarea.remove();
      }
    } catch (e) {
      debug('error', 'clipboard copy failed:', e);
      return;
    }

    const prior = wCopiedTimers.get(el);
    if (prior) clearTimeout(prior.timer);
    const originalHtml = prior ? prior.html : el.innerHTML;
    el.classList.add('w-copied');
    const copiedLabel = el.getAttribute('w-copied-label');
    if (copiedLabel) el.textContent = copiedLabel;
    const timer = setTimeout(function() {
      el.classList.remove('w-copied');
      if (copiedLabel) el.innerHTML = originalHtml;
      wCopiedTimers.delete(el);
    }, 1500);
    wCopiedTimers.set(el, { timer: timer, html: originalHtml });
  }

  /**
   * Toggle dark/light theme on <html> via w-theme-toggle. Persists to
   * localStorage('w-theme'); the server injects a tiny head script that
   * re-applies the saved class before first paint (no FOUC).
   */
  function handleThemeToggle(event) {
    const el = event.target.closest('[w-theme-toggle]');
    if (!el) return;
    event.preventDefault();

    const root = document.documentElement.classList;
    const isDark = root.contains('dark') ||
      (!root.contains('light') && window.matchMedia('(prefers-color-scheme: dark)').matches);
    const next = isDark ? 'light' : 'dark';
    root.remove('dark', 'light');
    root.add(next);
    try { localStorage.setItem('w-theme', next); } catch (e) { /* storage disabled */ }
    document.dispatchEvent(new CustomEvent('w:theme', { detail: { theme: next } }));
  }

  // ================================
  // Link Handling (SPA Navigation)
  // ================================

  /**
   * Handle link clicks for SPA-like navigation
   */
  function handleLinkClick(event) {
    const link = event.target.closest('a');
    if (!link) return;

    // Skip if:
    // - External link
    // - Has target attribute
    // - Has download attribute
    // - Meta/ctrl key pressed (open in new tab)
    // - Has w-boost="false"
    if (
      link.hostname !== window.location.hostname ||
      link.hasAttribute('target') ||
      link.hasAttribute('download') ||
      link.hasAttribute('w-modal-trigger') ||
      link.hasAttribute('w-clipboard') ||
      link.hasAttribute('w-clipboard-from') ||
      link.hasAttribute('w-theme-toggle') ||
      event.metaKey || event.ctrlKey ||
      link.getAttribute('w-boost') === 'false'
    ) {
      return;
    }

    // Check if link matches our selector
    const href = link.getAttribute('href');
    if (!href || href.startsWith('#') || href.startsWith('javascript:')) {
      return;
    }

    // Honor w-confirm on boosted links
    const linkConfirm = link.getAttribute('w-confirm');
    if (linkConfirm && !window.confirm(linkConfirm)) {
      event.preventDefault();
      return;
    }

    event.preventDefault();
    debug('boost →', href);
    navigateTo(href);
  }

  /**
   * Navigate to a URL with SPA behavior
   */
  async function navigateTo(url, options = {}) {
    const { replace = false, scroll = true, restoreScrollY = null } = options;

    try {
      // Show loading state
      document.body.classList.add(CONFIG.loadingClass);

      // Check cache first
      let html = pageCache.get(url);
      if (!html) {
        const response = await fetch(url, {
          headers: {
            'X-Requested-With': 'What',
            'Accept': 'text/html'
          }
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        html = await response.text();

        // Cache the response
        pageCache.set(url, html);
        setTimeout(() => pageCache.delete(url), CONFIG.cacheTimeout);
      }

      // Update browser history
      if (replace) {
        history.replaceState({ url }, '', url);
      } else {
        // Stamp the outgoing entry with its scroll position (and its url —
        // the initial entry has null state, which used to leave back/forward
        // to the first page doing nothing) so popstate can restore both.
        const current = history.state || {};
        history.replaceState(
          {
            url: current.url || (window.location.pathname + window.location.search),
            scrollY: window.scrollY
          },
          '',
          window.location.href
        );
        history.pushState({ url }, '', url);
      }

      // Swap page content (wait for stylesheets before revealing)
      await swapPageContent(html);

      // Restore the saved position on back/forward, otherwise scroll to top
      if (restoreScrollY !== null) {
        window.scrollTo(0, restoreScrollY);
      } else if (scroll) {
        window.scrollTo(0, 0);
      }

      // Re-initialize elements
      initializeElements();

    } catch (error) {
      console.error('[What] Navigation error:', error);
      // Fallback to regular navigation
      window.location.href = url;
    } finally {
      document.body.classList.remove(CONFIG.loadingClass);
    }
  }

  /**
   * Handle browser back/forward navigation
   */
  function handlePopState(event) {
    if (event.state && event.state.url) {
      const y = typeof event.state.scrollY === 'number' ? event.state.scrollY : 0;
      navigateTo(event.state.url, { replace: true, scroll: false, restoreScrollY: y });
    }
  }

  function getStylesheetKey(link) {
    return [
      link.getAttribute('href') || '',
      link.getAttribute('media') || '',
      link.getAttribute('rel') || ''
    ].join('::');
  }

  // Sync the new page's head <style> blocks (e.g. the inlined critical layout
  // skeleton that prevents sidebar FOUC). Hard loads get these for free; SPA
  // swaps must install them too, BEFORE the body swap, so the skeleton styles
  // the incoming content even if a <link> stylesheet is still downloading.
  // Returns the stale synced blocks to remove after the swap.
  function syncHeadInlineStyles(doc) {
    const currentTexts = new Set(
      Array.from(document.head.querySelectorAll('style')).map(function(s) { return s.textContent; })
    );
    const nextTexts = new Set(
      Array.from(doc.head.querySelectorAll('style')).map(function(s) { return s.textContent; })
    );

    Array.from(doc.head.querySelectorAll('style')).forEach(function(style) {
      if (!currentTexts.has(style.textContent)) {
        const clone = document.createElement('style');
        clone.textContent = style.textContent;
        clone.setAttribute('data-w-synced', '');
        document.head.appendChild(clone);
      }
    });

    // Previously-synced blocks the new page doesn't want — removed post-swap
    return Array.from(document.head.querySelectorAll('style[data-w-synced]'))
      .filter(function(style) { return !nextTexts.has(style.textContent); });
  }

  function syncHeadStylesheets(doc) {
    const selector = 'link[rel~="stylesheet"][href]';
    const nextLinks = Array.from(doc.head.querySelectorAll(selector));
    const nextKeys = new Set(nextLinks.map(getStylesheetKey));

    // Stale links are NOT removed here: the outgoing page must stay styled
    // while the new stylesheets download. The caller removes them post-swap.
    const staleLinks = Array.from(document.head.querySelectorAll(selector))
      .filter(function(link) { return !nextKeys.has(getStylesheetKey(link)); });

    const existingKeys = new Set(
      Array.from(document.head.querySelectorAll(selector)).map(getStylesheetKey)
    );
    const firstScript = document.head.querySelector('script');
    var pendingLoads = [];

    nextLinks.forEach(function(link) {
      const key = getStylesheetKey(link);
      if (existingKeys.has(key)) {
        return;
      }

      const clone = link.cloneNode(true);
      pendingLoads.push(new Promise(function(resolve) {
        clone.onload = resolve;
        clone.onerror = resolve;
        // Failsafe only (broken/hanging stylesheet) — generous enough that a
        // cold-cache download never races it; the inline critical skeleton
        // covers layout in the unlikely event it fires.
        setTimeout(resolve, 3000);
      }));
      if (firstScript) {
        document.head.insertBefore(clone, firstScript);
      } else {
        document.head.appendChild(clone);
      }
      existingKeys.add(key);
    });

    return {
      ready: pendingLoads.length ? Promise.all(pendingLoads) : Promise.resolve(),
      staleLinks: staleLinks
    };
  }

  /**
   * Swap page content with new HTML
   */
  async function swapPageContent(html) {
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, 'text/html');

    // Update title
    const newTitle = doc.querySelector('title');
    if (newTitle) {
      document.title = newTitle.textContent;
    }

    // Sync page-specific stylesheets and wait for new ones to load before swapping.
    // This prevents FOUC when navigating between layouts with different CSS.
    // Inline critical styles install immediately; stale assets are removed only
    // AFTER the swap so neither the outgoing nor incoming page paints unstyled.
    var staleStyles = syncHeadInlineStyles(doc);
    var cssSync = syncHeadStylesheets(doc);

    // Update body content
    const newBody = doc.querySelector('body');
    if (newBody) {
      // Wait for new stylesheets to load before revealing content
      await cssSync.ready;

      // Copy body attributes (e.g. style, class) from new page
      Array.from(newBody.attributes).forEach(function(attr) {
        document.body.setAttribute(attr.name, attr.value);
      });

      // Preserve any elements marked with w-persist
      const persistElements = document.querySelectorAll('[w-persist]');
      const persistData = new Map();

      persistElements.forEach(el => {
        const id = el.getAttribute('w-persist');
        persistData.set(id, el.cloneNode(true));
      });

      // Swap body content
      document.body.innerHTML = newBody.innerHTML;

      // Now that the new content is in place, drop the previous page's assets
      cssSync.staleLinks.forEach(function(link) { link.remove(); });
      staleStyles.forEach(function(style) { style.remove(); });

      // Restore persisted elements
      persistData.forEach((el, id) => {
        const placeholder = document.querySelector(`[w-persist="${id}"]`);
        if (placeholder) {
          placeholder.replaceWith(el);
        }
      });

      // Execute scripts in the swapped content (inline scripts don't auto-run via innerHTML)
      executeScripts(document.body);
    }
  }

  // ================================
  // Form Handling
  // ================================

  /**
   * Handle form submissions
   */
  async function handleFormSubmit(event) {
    const form = event.target;
    if (!form.matches(CONFIG.formSelector)) return;

    // Check for confirmation
    const confirmMsg = form.getAttribute('w-confirm');
    if (confirmMsg && !confirm(confirmMsg)) {
      event.preventDefault();
      return;
    }

    // If form has w-target, handle via AJAX
    const target = form.getAttribute('w-target');
    if (target || form.hasAttribute('w-boost')) {
      event.preventDefault();
      const action = form.getAttribute('action') || window.location.href;
      debug('form submit →', action, target ? `(target: ${target})` : '(boost)');
      await submitForm(form);
    }
    // Otherwise, let browser handle the submission
  }

  /**
   * Submit form via AJAX
   */
  async function submitForm(form) {
    const target = form.getAttribute('w-target');
    const swap = form.getAttribute('w-swap') || 'innerHTML';
    const loadingClass = form.getAttribute('w-loading') || CONFIG.loadingClass;

    // Get form data
    const formData = new FormData(form);
    const method = (form.getAttribute('method') || 'POST').toUpperCase();
    let url = form.getAttribute('action') || window.location.href;

    // Add loading state
    form.classList.add(loadingClass);
    const submitBtn = form.querySelector('[type="submit"]');
    if (submitBtn) {
      submitBtn.disabled = true;
      submitBtn.classList.add('btn-loading');
    }

    try {
      // Use multipart only for file uploads; urlencoded for everything else
      const hasFiles = form.querySelector('input[type="file"]') && form.querySelector('input[type="file"]').files.length > 0;
      const body = hasFiles ? formData : new URLSearchParams(formData);
      const headers = addCsrfHeader({ 'X-Requested-With': 'What' });
      if (!hasFiles) headers['Content-Type'] = 'application/x-www-form-urlencoded';
      const response = await fetch(url, {
        method,
        body,
        headers,
        redirect: 'follow'
      });

      // Clear page cache after any mutation to avoid stale data
      pageCache.clear();

      // Handle redirect
      if (response.redirected) {
        navigateTo(response.url, { replace: true });
        return;
      }

      // Get response HTML
      const html = await response.text();

      // If we have a target, update it
      if (target) {
        const targetEl = document.querySelector(target);
        if (targetEl) {
          swapContent(targetEl, html, swap);
        }
      } else {
        // Otherwise, swap the whole page
        await swapPageContent(html);
      }

      // Clear form if successful
      if (response.ok && form.hasAttribute('w-reset')) {
        form.reset();
      }

      // Fire w-set side-effect if present (e.g., notify other clients via wired)
      var setExpr = form.getAttribute('w-set');
      if (setExpr && response.ok) {
        fetch('/w-set', {
          method: 'POST',
          headers: addCsrfHeader({
            'Content-Type': 'application/x-www-form-urlencoded',
            'X-Requested-With': 'What'
          }),
          body: 'expr=' + encodeURIComponent(setExpr)
        });
      }

      // Dispatch success event
      form.dispatchEvent(new CustomEvent('w:success', {
        detail: { response, html }
      }));


    } catch (error) {
      console.error('[What] Form submission error:', error);

      // Dispatch error event
      form.dispatchEvent(new CustomEvent('w:error', {
        detail: { error }
      }));
    } finally {
      form.classList.remove(loadingClass);
      if (submitBtn) {
        submitBtn.disabled = false;
        submitBtn.classList.remove('btn-loading');
      }
    }
  }

  // ================================
  // Trigger Handling
  // ================================

  /**
   * Parse a w-trigger attribute into tokens.
   * Grammar: comma-separated list of: click | change | load | revealed | poll <interval>
   * e.g. w-trigger="load, poll 30s"
   */
  function parseWTriggers(value) {
    if (!value) return [];
    return value.split(',').map(function(part) {
      const words = part.trim().split(/\s+/);
      if (words[0] === 'poll') {
        return { type: 'poll', interval: parseWInterval(words[1]) };
      }
      return { type: words[0] };
    }).filter(function(t) { return t.type; });
  }

  /**
   * Parse an interval like "500ms", "5s", "2m", "1h" or a bare number (seconds).
   * Returns milliseconds, or null if invalid.
   */
  function parseWInterval(s) {
    const match = /^(\d+)(ms|s|m|h)?$/.exec(s || '');
    if (!match) return null;
    return parseInt(match[1], 10) * { ms: 1, s: 1000, m: 60000, h: 3600000 }[match[2] || 's'];
  }

  /**
   * Arm load/revealed/poll triggers on w-get/w-post elements.
   * Runs from initializeElements() — on page load and after every swap.
   * Each element is armed at most once (wArmedTriggers); active timers and
   * observers register a cancel entry in wFetchLifecycles so they die with
   * their element (swept after swaps, plus an isConnected guard per tick).
   */
  function initFetchTriggers() {
    document.querySelectorAll('[w-trigger]').forEach(function(el) {
      if (!el.hasAttribute('w-get') && !el.hasAttribute('w-post')) return;
      if (wArmedTriggers.has(el)) return;
      wArmedTriggers.add(el);

      parseWTriggers(el.getAttribute('w-trigger')).forEach(function(trigger) {
        if (trigger.type === 'load') {
          if ((el.getAttribute('w-swap') || '') === 'outerHTML' && !el.getAttribute('w-target')) {
            debug('warn', 'w-trigger="load" with w-swap="outerHTML" on self re-arms every response — possible fetch loop', el);
          }
          doPartialFetch(el, { confirm: false });
        } else if (trigger.type === 'revealed') {
          const entry = { el: el, cancel: null };
          const observer = new IntersectionObserver(function(entries) {
            if (entries.some(function(e) { return e.isIntersecting; })) {
              observer.disconnect();
              const idx = wFetchLifecycles.indexOf(entry);
              if (idx !== -1) wFetchLifecycles.splice(idx, 1);
              doPartialFetch(el, { confirm: false });
            }
          });
          entry.cancel = function() { observer.disconnect(); };
          observer.observe(el);
          wFetchLifecycles.push(entry);
        } else if (trigger.type === 'poll') {
          if (!trigger.interval) {
            debug('warn', 'invalid poll interval in w-trigger:', el.getAttribute('w-trigger'));
            return;
          }
          const entry = { el: el, cancel: null };
          const id = setInterval(function() {
            // Self-cancel if the element was removed outside a framework swap
            if (!el.isConnected) {
              entry.cancel();
              const idx = wFetchLifecycles.indexOf(entry);
              if (idx !== -1) wFetchLifecycles.splice(idx, 1);
              return;
            }
            // No request pile-up in hidden tabs or on slow endpoints
            if (document.hidden || el._wFetchBusy) return;
            doPartialFetch(el, { confirm: false, silentError: true });
          }, trigger.interval);
          entry.cancel = function() { clearInterval(id); };
          wFetchLifecycles.push(entry);
        }
      });
    });
  }

  /**
   * Cancel timers/observers whose element is no longer in the DOM.
   */
  function sweepFetchLifecycles() {
    for (let i = wFetchLifecycles.length - 1; i >= 0; i--) {
      if (!wFetchLifecycles[i].el.isConnected) {
        wFetchLifecycles[i].cancel();
        wFetchLifecycles.splice(i, 1);
      }
    }
  }

  /**
   * Handle click triggers
   */
  function handleTriggerClick(event) {
    const trigger = event.target.closest('[w-trigger="click"]');
    if (!trigger) return;

    event.preventDefault();
    executeTrigger(trigger);
  }

  /**
   * Handle change triggers
   */
  function handleTriggerChange(event) {
    const trigger = event.target.closest('[w-trigger="change"]');
    if (!trigger) return;

    executeTrigger(trigger);
  }

  /**
   * Handle partial fetch (w-get, w-post) - HTML Injection
   *
   * Attributes:
   * - w-get="url"     : Fetch HTML via GET and inject into target
   * - w-post="url"    : Fetch HTML via POST and inject into target
   * - w-target="sel"  : CSS selector for target element (default: the element itself)
   * - w-swap="mode"   : How to inject: replace (default), prepend, append, before, after
   * - w-trigger="..."  : load | revealed | poll <interval> | click (default)
   * - w-confirm="msg" : Show confirmation before fetch
   * - w-loading="cls" : CSS class to add during loading
   * - w-params="json" : JSON object of params to send with request
   * - w-include="sel" : Include form values from selector
   */
  async function handlePartialFetch(event) {
    const element = event.target.closest('[w-get], [w-post]');
    if (!element) return;

    // If element also has w-set, let handleWSet coordinate both operations
    if (element.hasAttribute('w-set')) return;

    // Elements with programmatic triggers (load/revealed/poll) only fetch on
    // click when "click" is also listed
    const triggers = parseWTriggers(element.getAttribute('w-trigger'));
    if (triggers.length && !triggers.some(function(t) { return t.type === 'click'; })) return;

    event.preventDefault();
    await doPartialFetch(element);
  }

  /**
   * Perform a partial fetch for an element with w-get or w-post.
   * Called from the click handler and programmatically (w-watch, w-trigger
   * load/revealed/poll — these pass opts.confirm=false; poll also passes
   * opts.silentError=true so a transient failure never destroys live content).
   */
  async function doPartialFetch(element, opts = {}) {
    const url = element.getAttribute('w-get') || element.getAttribute('w-post');
    const method = element.hasAttribute('w-post') ? 'POST' : 'GET';
    const target = element.getAttribute('w-target');
    const swap = element.getAttribute('w-swap') || 'innerHTML';
    const confirmMsg = element.getAttribute('w-confirm');
    const loadingClass = element.getAttribute('w-loading') || CONFIG.loadingClass;
    const paramsAttr = element.getAttribute('w-params');
    const includeSelector = element.getAttribute('w-include');

    // Target defaults to the element itself ("this" makes that explicit)
    const targetEl = (target && target !== 'this') ? document.querySelector(target) : element;
    if (!targetEl) {
      console.warn('[What] Target not found:', target);
      return;
    }

    // Confirmation (skipped for programmatic triggers)
    if (confirmMsg && opts.confirm !== false && !window.confirm(confirmMsg)) {
      return;
    }

    // Add loading state
    element.classList.add(loadingClass);
    targetEl.classList.add(loadingClass);
    element._wFetchBusy = true;

    try {
      // Build request options
      const options = {
        method,
        headers: addCsrfHeader({
          'X-Requested-With': 'What',
          'Accept': 'text/html'
        })
      };

      // Build params
      let params = new URLSearchParams();

      // Parse w-params JSON
      if (paramsAttr) {
        try {
          const paramsObj = JSON.parse(paramsAttr);
          Object.entries(paramsObj).forEach(([k, v]) => params.append(k, v));
        } catch (e) {
          console.warn('[What] Invalid w-params JSON:', paramsAttr);
        }
      }

      // Include form values
      if (includeSelector) {
        const form = document.querySelector(includeSelector);
        if (form) {
          const formData = new FormData(form);
          formData.forEach((v, k) => params.append(k, v));
        }
      }

      // Build final URL or body
      let finalUrl = url;
      if (method === 'GET' && params.toString()) {
        finalUrl = url + (url.includes('?') ? '&' : '?') + params.toString();
      } else if (method === 'POST') {
        options.body = params;
        options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
      }

      // Fetch HTML
      const response = await fetch(finalUrl, options);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const html = await response.text();

      // Extract OOB updates from response (if any)
      const { cleanHtml, updates } = extractOobUpdates(html);

      // Swap cleaned content (without OOB template)
      debug(`inject ${swap} →`, target, `(from ${method} ${url})`);

      // For prepend/append modes, clean w-bind from HTML BEFORE injection
      // to preserve w-bind on pre-existing elements in the target
      const preservesModes = ['prepend', 'afterbegin', 'append', 'beforeend', 'before', 'beforebegin', 'after', 'afterend'];
      if (preservesModes.includes(swap)) {
        // Parse HTML, strip w-bind, then inject
        const temp = document.createElement('div');
        temp.innerHTML = cleanHtml;
        temp.querySelectorAll('[w-bind]').forEach(el => el.removeAttribute('w-bind'));
        swapContent(targetEl, temp.innerHTML, swap);
      } else {
        // For replace modes, inject first then strip (existing behavior)
        swapContent(targetEl, cleanHtml, swap);
        // Remove w-bind from injected content so OOB updates don't affect it
        // The injected content already has correct server-rendered values
        // Keep wired.* bindings so WebSocket updates from other clients still work
        targetEl.querySelectorAll('[w-bind]').forEach(el => {
          var bind = el.getAttribute('w-bind');
          if (bind && !bind.startsWith('wired.')) {
            el.removeAttribute('w-bind');
          }
        });
      }

      // Apply w-bind updates to reactive elements across the page
      // This will only find elements OUTSIDE the injected content now
      if (updates) {
        debug('applying OOB updates:', updates);
        applyDataBindUpdates(updates);
      }

      // Re-initialize any new w-* elements
      initializeElements();

      element.classList.remove('w-fetch-error');

      // Dispatch custom event
      targetEl.dispatchEvent(new CustomEvent('w:load', {
        bubbles: true,
        detail: { url, method, swap, updates }
      }));


    } catch (error) {
      console.error('[What] Partial fetch error:', error);
      element.classList.add('w-fetch-error');
      element.dispatchEvent(new CustomEvent('w:error', {
        bubbles: true,
        detail: { url, method, error: String(error) }
      }));
      // Show inline error unless this is a background poll tick
      if (!opts.silentError) {
        targetEl.innerHTML = `<div class="text-red-600 p-4">Failed to load content</div>`;
      }
    } finally {
      element._wFetchBusy = false;
      element.classList.remove(loadingClass);
      targetEl.classList.remove(loadingClass);
    }
  }

  // ================================
  // Declarative State Mutations (w-set)
  // ================================

  /**
   * Handle w-set attribute clicks — declarative state mutations
   *
   * Usage:
   *   <button w-set="session.counter += 1">+1</button>
   *   <button w-set="app.visits = 0">Reset</button>
   *   <button w-set="session.counter += 1" w-target="#count">+1</button>
   */
  async function handleWSet(event) {
    const el = event.target.closest('[w-set]');
    if (!el) return;

    // w-set on <form> elements is handled by submitForm(), not here
    if (el.tagName === 'FORM') return;

    event.preventDefault();

    const rawExpr = el.getAttribute('w-set');
    const target = el.getAttribute('w-target');
    const swap = el.getAttribute('w-swap') || 'innerHTML';
    const loadingClass = el.getAttribute('w-loading') || CONFIG.loadingClass;

    // Resolve $value for input/select/textarea elements
    var expr = rawExpr;
    if (el.value !== undefined && rawExpr.indexOf('$value') !== -1) {
      var escaped = el.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
      expr = rawExpr.replace(/\$value/g, '"' + escaped + '"');
    }

    // Add loading state
    el.classList.add(loadingClass);

    try {
      const formData = new URLSearchParams();
      formData.append('expr', expr);

      const fetchHeaders = addCsrfHeader({
        'X-Requested-With': 'What',
        'Content-Type': 'application/x-www-form-urlencoded'
      });

      const response = await fetch('/w-set', {
        method: 'POST',
        body: formData,
        headers: fetchHeaders
      });

      // Clear page cache after state mutation
      pageCache.clear();

      if (response.redirected) {
        navigateTo(response.url, { replace: true });
        return;
      }

      const html = await response.text();
      if (!html) return;

      // Extract and apply OOB updates
      const result = extractOobUpdates(html);
      if (result.updates) {
        debug('w-set updates:', result.updates);
        applyDataBindUpdates(result.updates);
      }

      // If w-target specified, swap content from w-set response
      if (target) {
        const targetEl = document.querySelector(target);
        if (targetEl) {
          swapContent(targetEl, result.cleanHtml, swap);
        }
      }

      // If element also has w-get/w-post, fetch partial AFTER mutation completes
      const partialUrl = el.getAttribute('w-get') || el.getAttribute('w-post');
      if (partialUrl && target) {
        const partialMethod = el.hasAttribute('w-post') ? 'POST' : 'GET';
        const partialHeaders = addCsrfHeader({
          'X-Requested-With': 'What',
          'Accept': 'text/html'
        });
        const partialResponse = await fetch(partialUrl, {
          method: partialMethod,
          headers: partialHeaders
        });
        if (partialResponse.ok) {
          const partialHtml = await partialResponse.text();
          const partialResult = extractOobUpdates(partialHtml);
          const targetEl = document.querySelector(target);
          if (targetEl) {
            swapContent(targetEl, partialResult.cleanHtml, swap);
          }
          if (partialResult.updates) {
            applyDataBindUpdates(partialResult.updates);
          }
        }
      }


    } catch (error) {
      console.error('[What] w-set error:', error);
    } finally {
      el.classList.remove(loadingClass);
    }
  }

  /**
   * Debounced w-set handler for input events (typing in text fields).
   * Waits 300ms after the user stops typing before firing.
   */
  var _wSetInputTimers = new WeakMap();
  function handleWSetInput(event) {
    var el = event.target.closest('[w-set]');
    if (!el) return;
    if (el.value === undefined) return;

    clearTimeout(_wSetInputTimers.get(el));
    _wSetInputTimers.set(el, setTimeout(function() {
      handleWSet(event);
    }, 300));
  }

  /**
   * Connect to wired state WebSocket for real-time updates across all clients.
   * Only connects if the page contains wired-bound elements.
   */
  function initWire() {
    var hasBindings = document.querySelector('[w-bind^="wired."], [data-w-src^="wired."], [data-w-href^="wired."], [w-watch^="wired."]');
    var statusEls = document.querySelectorAll('.w-wire-status');
    if (!hasBindings && statusEls.length === 0) return;

    var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
    var url = proto + '//' + location.host + '/w-wire';
    var ws = new WebSocket(url);
    var reconnectDelay = 1000;

    function setStatusConnected(connected) {
      statusEls.forEach(function(el) {
        if (connected) el.classList.add('connected');
        else el.classList.remove('connected');
      });
    }

    ws.onopen = function() {
      debug('Wired WebSocket connected');
      reconnectDelay = 1000;
      setStatusConnected(true);
    };

    ws.onmessage = function(event) {
      try {
        var data = JSON.parse(event.data);
        if (data.type === 'connected') {
          setStatusConnected(true);
          return;
        }
        debug('Wired update:', data);
        applyDataBindUpdates(data);
        // Trigger w-watch elements when their wired variable changes
        Object.keys(data).forEach(function(path) {
          document.querySelectorAll('[w-watch~="' + path + '"]').forEach(function(el) {
            if (el.hasAttribute('w-get') || el.hasAttribute('w-post')) {
              doPartialFetch(el, { confirm: false });
            }
          });
        });
      } catch (e) {
        // ignore non-JSON
      }
    };

    ws.onclose = function() {
      debug('Wired WebSocket closed, reconnecting in ' + reconnectDelay + 'ms');
      setStatusConnected(false);
      setTimeout(function() {
        reconnectDelay = Math.min(reconnectDelay * 2, 30000);
        initWire();
      }, reconnectDelay);
    };

    ws.onerror = function() {
      ws.close();
    };
  }

  /**
   * Execute a trigger action
   */
  async function executeTrigger(element) {
    const action = element.getAttribute('w-action');
    const url = element.getAttribute('w-url') || element.getAttribute('href');
    const target = element.getAttribute('w-target');
    const swap = element.getAttribute('w-swap') || 'innerHTML';
    const confirm = element.getAttribute('w-confirm');
    const loadingClass = element.getAttribute('w-loading') || CONFIG.loadingClass;

    // Confirmation
    if (confirm && !window.confirm(confirm)) {
      return;
    }

    // Add loading state
    element.classList.add(loadingClass);

    try {
      // Handle different actions
      switch (action) {
        case 'toggle':
          toggleElement(element.getAttribute('w-toggle'));
          break;

        case 'remove':
          removeElement(target || element);
          break;

        case 'navigate':
          if (url) navigateTo(url);
          break;

        case 'delete':
          await executeDelete(url, target, swap);
          break;

        default:
          // Fetch and swap content
          if (url) {
            const html = await fetchContent(url);
            if (target) {
              const targetEl = document.querySelector(target);
              if (targetEl) swapContent(targetEl, html, swap);
            }
          }
      }
    } catch (error) {
      console.error('[What] Trigger error:', error);
    } finally {
      element.classList.remove(loadingClass);
    }
  }

  /**
   * Execute DELETE request
   */
  async function executeDelete(url, target, swap) {
    const response = await fetch(url, {
      method: 'POST',
      headers: addCsrfHeader({
        'X-Requested-With': 'What'
      }),
      body: new URLSearchParams({ 'w-action': 'delete' })
    });

    if (response.redirected) {
      navigateTo(response.url, { replace: true });
    } else if (target) {
      const targetEl = document.querySelector(target);
      if (targetEl) {
        targetEl.remove();
      }
    }
  }

  // ================================
  // Reactive Data Binding
  // ================================

  /**
   * Apply w-bind updates to all matching elements on the page
   * Updates any element with w-bind="path" attribute to the new value
   * @param {Object} updates - Object mapping paths to values, e.g. {"session.count": 8}
   */
  function applyDataBindUpdates(updates) {
    if (!updates || typeof updates !== 'object') {
      return;
    }

    if (DEBUG) console.group('[What] Live Updates');
    for (const [path, value] of Object.entries(updates)) {
      // Update w-bind elements (element-type-aware)
      const elements = document.querySelectorAll(`[w-bind="${path}"]`);
      if (DEBUG) debug('verbose', `w-bind="${path}" → "${value}" (${elements.length} elements)`);
      elements.forEach(el => {
        var tag = el.tagName;
        if (tag === 'IMG' || tag === 'SOURCE' || tag === 'VIDEO' || tag === 'AUDIO' || tag === 'IFRAME') {
          el.setAttribute('src', String(value));
        } else if (tag === 'A') {
          el.setAttribute('href', String(value));
        } else {
          el.textContent = String(value);
        }
      });

      // Update attribute-bound elements (e.g. img[data-w-src="wired.dog_url"])
      var srcEls = document.querySelectorAll(`[data-w-src="${path}"]`);
      srcEls.forEach(el => {
        el.setAttribute('src', String(value));
      });
      var hrefEls = document.querySelectorAll(`[data-w-href="${path}"]`);
      hrefEls.forEach(el => {
        el.setAttribute('href', String(value));
      });
    }
    if (DEBUG) console.groupEnd();
  }

  /**
   * Extract OOB updates template from HTML string
   * Looks for <template data-what-updates>JSON</template>
   * @param {string} html - The HTML response
   * @returns {{cleanHtml: string, updates: Object|null}} - HTML without template, and updates object
   */
  function extractOobUpdates(html) {
    const templateRegex = /<template\s+data-what-updates>([\s\S]*?)<\/template>/i;
    const match = html.match(templateRegex);

    if (!match) {
      return { cleanHtml: html, updates: null };
    }

    // Remove the template from HTML
    const cleanHtml = html.replace(match[0], '');

    // Parse the JSON
    try {
      const updates = JSON.parse(match[1]);
      return { cleanHtml, updates };
    } catch (e) {
      console.warn('[What] Invalid OOB updates JSON:', match[1]);
      return { cleanHtml, updates: null };
    }
  }

  // ================================
  // Utility Functions
  // ================================

  /**
   * Fetch content from URL
   */
  async function fetchContent(url) {
    const response = await fetch(url, {
      headers: {
        'X-Requested-With': 'What',
        'Accept': 'text/html'
      }
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    return response.text();
  }

  /**
   * Swap content in target element
   */
  function swapContent(target, html, mode = 'replace') {
    switch (mode) {
      case 'replace':
      case 'innerHTML':
      case 'inner':
        target.innerHTML = html;
        break;

      case 'outerHTML':
      case 'outer':
        target.outerHTML = html;
        break;

      case 'beforebegin':
      case 'before':
        target.insertAdjacentHTML('beforebegin', html);
        break;

      case 'afterbegin':
      case 'prepend':
        target.insertAdjacentHTML('afterbegin', html);
        break;

      case 'beforeend':
      case 'append':
        target.insertAdjacentHTML('beforeend', html);
        break;

      case 'afterend':
      case 'after':
        target.insertAdjacentHTML('afterend', html);
        break;

      case 'none':
        // Don't swap, just fetch
        break;
    }

    // Execute any scripts in the swapped content
    executeScripts(target);

    // Re-initialize elements in the swapped content
    initializeElements();

    // Auto-scroll after swap (w-scroll="bottom" or w-scroll="top")
    var scrollEl = target.hasAttribute('w-scroll') ? target : target.closest('[w-scroll]');
    if (scrollEl) {
      var dir = scrollEl.getAttribute('w-scroll');
      if (dir === 'bottom') scrollEl.scrollTop = scrollEl.scrollHeight;
      else if (dir === 'top') scrollEl.scrollTop = 0;
    }
  }

  /**
   * Execute scripts in injected HTML content.
   * Only inline scripts from same-origin are executed.
   * External scripts with src pointing to other domains are skipped for security.
   */
  function executeScripts(container) {
    const scripts = container.querySelectorAll('script');
    scripts.forEach(oldScript => {
      // Skip external scripts from other domains
      var src = oldScript.getAttribute('src');
      if (src) {
        try {
          var url = new URL(src, window.location.origin);
          if (url.origin !== window.location.origin) {
            debug('warn', 'Blocked external script:', src);
            oldScript.remove();
            return;
          }
        } catch (e) {
          oldScript.remove();
          return;
        }
      }
      const newScript = document.createElement('script');
      // Copy attributes
      Array.from(oldScript.attributes).forEach(attr => {
        newScript.setAttribute(attr.name, attr.value);
      });
      // Copy inline content
      newScript.textContent = oldScript.textContent;
      // Replace old script with new one to execute it
      oldScript.parentNode.replaceChild(newScript, oldScript);
    });
  }

  /**
   * Toggle element visibility
   */
  function toggleElement(selector) {
    const element = document.querySelector(selector);
    if (element) {
      element.classList.toggle('hidden');
    }
  }

  /**
   * Remove element
   */
  function removeElement(selectorOrElement) {
    const element = typeof selectorOrElement === 'string'
      ? document.querySelector(selectorOrElement)
      : selectorOrElement;

    if (element) {
      element.remove();
    }
  }

  /**
   * Toggle modal
   */
  function toggleModal(modal, show) {
    if (show) {
      modal.classList.add(CONFIG.activeClass);
      document.body.style.overflow = 'hidden';
    } else {
      modal.classList.remove(CONFIG.activeClass);
      document.body.style.overflow = '';
    }
  }

  // ================================
  // Live Reload (Development Mode)
  // ================================

  let liveReloadSocket = null;
  let liveReloadReconnectTimer = null;
  let liveReloadEnabled = false;

  /**
   * Connect to live reload WebSocket
   */
  function connectLiveReload() {
    if (liveReloadSocket && liveReloadSocket.readyState === WebSocket.OPEN) {
      return; // Already connected
    }

    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${protocol}//${window.location.host}/w-livereload`;

    try {
      liveReloadSocket = new WebSocket(wsUrl);

      liveReloadSocket.onopen = function() {
        DEBUG = true;
        console.log('[What] DEVELOPMENT mode - debug logging enabled');
        console.log('[What] Live reload connected');
        liveReloadEnabled = true;
        // Clear any reconnect timer
        if (liveReloadReconnectTimer) {
          clearTimeout(liveReloadReconnectTimer);
          liveReloadReconnectTimer = null;
        }
      };

      liveReloadSocket.onmessage = function(event) {
        try {
          const data = JSON.parse(event.data);
          if (data.type === 'reload') {
            console.log('[What] Reloading page...');
            // Clear local cache
            pageCache.clear();
            // Reload the page
            window.location.reload();
          } else if (data.type === 'cache_cleared') {
            console.log('[What] Server cache cleared');
            pageCache.clear();
          } else if (data.type === 'connected') {
            console.log('[What] Live reload ready');
          }
        } catch (e) {
          console.warn('[What] Invalid live reload message:', event.data);
        }
      };

      liveReloadSocket.onclose = function() {
        liveReloadEnabled = false;
        // Attempt to reconnect after a delay
        if (!liveReloadReconnectTimer) {
          liveReloadReconnectTimer = setTimeout(() => {
            liveReloadReconnectTimer = null;
            console.log('[What] Attempting to reconnect live reload...');
            connectLiveReload();
          }, 2000);
        }
      };

      liveReloadSocket.onerror = function() {
        // Error will trigger onclose, which handles reconnection
        liveReloadSocket.close();
      };

    } catch (e) {
      console.warn('[What] Live reload not available:', e.message);
    }
  }

  /**
   * Disconnect live reload
   */
  function disconnectLiveReload() {
    if (liveReloadReconnectTimer) {
      clearTimeout(liveReloadReconnectTimer);
      liveReloadReconnectTimer = null;
    }
    if (liveReloadSocket) {
      liveReloadSocket.close();
      liveReloadSocket = null;
    }
    liveReloadEnabled = false;
  }

  // ================================
  // Public API
  // ================================

  window.What = {
    init,
    navigateTo,
    swapContent,
    toggleModal,
    submitForm,
    // Reactive data binding
    applyDataBindUpdates,
    extractOobUpdates,
    // Clear this browser's local page cache only
    clearCache: () => {
      pageCache.clear();
      console.log('[What] Local cache cleared');
    },
    // Show cached pages in this browser
    showCache: () => {
      const entries = Array.from(pageCache.keys());
      if (entries.length === 0) {
        console.log('[What] Cache is empty');
        return [];
      }
      console.log('[What] Cached pages:');
      entries.forEach((url, i) => {
        console.log(`  ${i + 1}. ${url}`);
      });
      return entries;
    },
    // Clear ALL caches (local + server) - dev mode only
    clearAllCaches: async () => {
      // Clear local cache
      pageCache.clear();
      console.log('[What] Local cache cleared');

      // Clear server cache (dev mode only)
      try {
        const response = await fetch('/w-cache/clear-all', {
          method: 'POST',
          headers: addCsrfHeader({ 'Content-Type': 'application/json' })
        });
        if (response.ok) {
          const result = await response.json();
          console.log('[What] Server cache cleared:', result.message);
          return { local: true, server: true };
        } else if (response.status === 404) {
          console.log('[What] Server cache clear not available (production mode)');
          return { local: true, server: false };
        } else {
          console.warn('[What] Failed to clear server cache');
          return { local: true, server: false };
        }
      } catch (e) {
        console.warn('[What] Error clearing server cache:', e.message);
        return { local: true, server: false };
      }
    },
    // List all active sessions (dev mode only)
    sessions: async () => {
      try {
        const response = await fetch('/w-sessions/list');
        if (response.ok) {
          const result = await response.json();
          console.log(`[What] Active sessions: ${result.count}`);
          if (result.ids && result.ids.length > 0) {
            result.ids.forEach((id, i) => {
              // Show truncated IDs for readability
              console.log(`  ${i + 1}. ${id.substring(0, 16)}...`);
            });
          }
          return result;
        } else if (response.status === 404) {
          console.log('[What] Session list not available (production mode)');
          return { count: 0, ids: [] };
        } else {
          console.warn('[What] Failed to get session list');
          return { count: 0, ids: [] };
        }
      } catch (e) {
        console.warn('[What] Error getting session list:', e.message);
        return { count: 0, ids: [] };
      }
    },
    // Clear session data (keeps session ID, clears stored data)
    clearSessionData: async () => {
      try {
        const response = await fetch('/w-session/clear-data', {
          method: 'POST',
          headers: addCsrfHeader({ 'Content-Type': 'application/json' })
        });
        if (response.ok) {
          const result = await response.json();
          console.log('[What] Session data cleared');
          return result;
        } else {
          console.warn('[What] Failed to clear session data');
          return { success: false };
        }
      } catch (e) {
        console.warn('[What] Error clearing session data:', e.message);
        return { success: false };
      }
    },
    // Data inspection (dev mode only)
    data: {
      show: async () => {
        try {
          const response = await fetch('/w-data/info');
          if (response.ok) {
            const result = await response.json();
            console.log('[What] Data Store:');
            console.log('  Application:', result.application);
            console.log('  Session:', result.session);
            return result;
          } else if (response.status === 404) {
            console.log('[What] Data info not available (production mode)');
            return { application: {}, session: {} };
          } else {
            console.warn('[What] Failed to get data info');
            return { application: {}, session: {} };
          }
        } catch (e) {
          console.warn('[What] Error getting data info:', e.message);
          return { application: {}, session: {} };
        }
      }
    },
    // Live reload controls
    connectLiveReload,
    disconnectLiveReload,
    isLiveReloadEnabled: () => liveReloadEnabled
  };

  // ================================
  // Client-Side Form Validation
  // ================================

  /**
   * Initialize client-side validation for forms with w-validate attribute.
   * Reads validation rules from the JWT hidden field (w-rules) and validates
   * on submit + blur for live feedback. Server-side validation always runs too.
   */
  function initFormValidation() {
    document.querySelectorAll('form[w-validate]').forEach(form => {
      if (form._whatValidationBound) return;
      form._whatValidationBound = true;

      form.addEventListener('submit', handleFormValidation);

      // Live validation on blur
      form.querySelectorAll('input, textarea, select').forEach(input => {
        if (input.type === 'hidden' || input.type === 'submit') return;
        input.addEventListener('blur', () => validateSingleField(form, input));
      });
    });
  }

  function handleFormValidation(e) {
    const form = e.target;
    const rules = decodeRulesFromForm(form);
    if (!rules || !rules.fields) return;

    const errors = {};
    for (const [fieldName, fieldRules] of Object.entries(rules.fields)) {
      const input = form.querySelector('[name="' + fieldName + '"]');
      if (!input) continue;
      const value = input.value.trim();
      const error = validateFieldValue(value, fieldRules, form);
      if (error) errors[fieldName] = error;
    }

    if (Object.keys(errors).length > 0) {
      e.preventDefault();
      showValidationErrors(form, errors);
    } else {
      clearValidationErrors(form);
    }
  }

  function validateSingleField(form, input) {
    const rules = decodeRulesFromForm(form);
    if (!rules || !rules.fields) return;

    const fieldName = input.getAttribute('name');
    const fieldRules = rules.fields[fieldName];
    if (!fieldRules) return;

    const value = input.value.trim();
    const error = validateFieldValue(value, fieldRules, form);

    clearFieldError(input);
    if (error) {
      showFieldError(input, error);
    }
  }

  function decodeRulesFromForm(form) {
    const hidden = form.querySelector('input[name="w-rules"]');
    if (!hidden) return null;
    try {
      // JWT payload is the middle part (base64url encoded)
      const parts = hidden.value.split('.');
      if (parts.length !== 3) return null;
      const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
      return payload;
    } catch(e) {
      return null;
    }
  }

  function validateFieldValue(value, rules, form) {
    if (rules.required && !value) {
      return rules.error_message || 'This field is required';
    }
    if (!value) return null;

    if (rules.min && value.length < rules.min) {
      return rules.error_message || 'Must be at least ' + rules.min + ' characters';
    }
    if (rules.max && value.length > rules.max) {
      return rules.error_message || 'Must be at most ' + rules.max + ' characters';
    }

    if (rules.field_type) {
      switch (rules.field_type) {
        case 'email':
          if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
            return rules.error_message || 'Invalid email address';
          break;
        case 'url':
          try { new URL(value); } catch {
            return rules.error_message || 'Invalid URL';
          }
          break;
        case 'number':
          if (isNaN(Number(value)))
            return rules.error_message || 'Must be a number';
          break;
        case 'phone':
          if (!/^\+?[\d\s\-()]{7,20}$/.test(value))
            return rules.error_message || 'Invalid phone number';
          break;
        case 'date':
          if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
            return rules.error_message || 'Invalid date (YYYY-MM-DD)';
          break;
        case 'time':
          if (!/^\d{2}:\d{2}(:\d{2})?$/.test(value))
            return rules.error_message || 'Invalid time (HH:MM)';
          break;
      }
    }

    if (rules.pattern) {
      // Limit pattern length to prevent ReDoS
      if (rules.pattern.length > 500) {
        return rules.error_message || 'Invalid format';
      }
      try {
        if (!new RegExp(rules.pattern).test(value)) {
          return rules.error_message || 'Invalid format';
        }
      } catch(e) { /* invalid regex, skip */ }
    }

    if (rules.match_field) {
      const other = form.querySelector('[name="' + rules.match_field + '"]');
      if (other && value !== other.value.trim()) {
        return rules.error_message || 'Must match ' + rules.match_field;
      }
    }

    return null;
  }

  function showValidationErrors(form, errors) {
    clearValidationErrors(form);
    for (const [field, message] of Object.entries(errors)) {
      const input = form.querySelector('[name="' + field + '"]');
      if (input) showFieldError(input, message);
    }
    // Scroll to first error
    const firstError = form.querySelector('.w-invalid');
    if (firstError) firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
  }

  function showFieldError(input, message) {
    input.classList.add('w-invalid');
    const errorEl = document.createElement('div');
    errorEl.className = 'w-field-error';
    errorEl.textContent = message;
    input.parentNode.insertBefore(errorEl, input.nextSibling);
  }

  function clearFieldError(input) {
    input.classList.remove('w-invalid');
    const next = input.nextElementSibling;
    if (next && next.classList.contains('w-field-error')) {
      next.remove();
    }
  }

  function clearValidationErrors(form) {
    form.querySelectorAll('.w-field-error').forEach(el => el.remove());
    form.querySelectorAll('.w-invalid').forEach(el => el.classList.remove('w-invalid'));
  }

  // Auto-initialize when DOM is ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => {
      init();
      // Only attempt live reload in dev mode (when what-debug meta tag is present)
      if (_debugMeta) connectLiveReload();
    });
  } else {
    init();
    if (_debugMeta) connectLiveReload();
  }

})();
