/**
 * Non-side-effect class export for `<toc-ui>`.
 *
 * Importing this file gives you the class without auto-registering the
 * tag. Useful for test isolation, subclassing with tag-name override,
 * or selective composition.
 *
 * The auto-register path stays at `@adia-ai/web-components/components/toc`
 * (which imports this file + calls `defineIfFree()`).
 *
 * @see ../../USAGE.md#registration--auto-vs-explicit
 */

/**
 * <toc-ui target="#article" headings="h2,h3"></toc-ui>
 *
 * Layout:
 *   <nav aria-label="Table of contents">
 *     <ul>
 *       <li><a href="#intro"    data-depth="0">Introduction</a></li>
 *       <li><a href="#setup"    data-depth="1">Setup</a></li>
 *       <li><a href="#conclude" data-depth="0">Conclusion</a></li>
 *     </ul>
 *   </nav>
 *
 * Depth is relative — the shallowest heading found becomes depth 0,
 * each level deeper gets +1. CSS indents via `[data-depth=N]`.
 *
 * Active state: an IntersectionObserver picks the heading nearest the
 * top of the viewport (below the configured offset). That heading's
 * link gets `[data-active]`. The `section-change` event fires when
 * the active id changes.
 */

import { UIElement } from '../../core/element.js';

function slugify(text) {
  return (text || '')
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    || 'section';
}

export class UITableOfContents extends UIElement {
  static properties = {
    target:   { type: String, default: '',                    reflect: true },
    headings: { type: String, default: 'h2,h3',               reflect: true },
    offset:   { type: Number, default: 80,                    reflect: true },
    label:    { type: String, default: 'Table of contents',   reflect: true },
  };

  static template = () => null;

  #observer = null;
  #activeId = null;
  #lastRoot = null;
  #lastHeadingsKey = '';
  #scheduledScan = false;

  #resolveRoot() {
    if (this.target) {
      const found = this.ownerDocument?.querySelector(this.target) || document.querySelector(this.target);
      if (!found) return null;
      return found;
    }
    return this.parentElement;
  }

  #scanHeadings() {
    const root = this.#resolveRoot();
    if (!root) return [];
    const selector = (this.headings || 'h2,h3').trim();
    const headings = Array.from(root.querySelectorAll(selector));
    // Exclude any headings that are descendants of this toc-ui itself
    // (avoid the toc reading its own stamped <a> labels when they
    // contain header tags, and avoid recursion when toc-ui is inside
    // the scan root).
    return headings.filter((h) => !this.contains(h));
  }

  #ensureIds(headings) {
    const doc = this.ownerDocument || document;
    for (const h of headings) {
      if (h.id) continue;
      const base = slugify(h.textContent);
      let id = base;
      let i = 1;
      while (doc.getElementById(id) && doc.getElementById(id) !== h) {
        id = `${base}-${i++}`;
      }
      h.id = id;
    }
  }

  #computeDepths(headings) {
    if (headings.length === 0) return [];
    // Heading levels (1..6) — find the shallowest as the depth-0 baseline.
    const levels = headings.map((h) => {
      const m = /^h([1-6])$/i.exec(h.tagName);
      return m ? parseInt(m[1], 10) : 6;
    });
    const minLevel = Math.min(...levels);
    return levels.map((l) => l - minLevel);
  }

  #render() {
    const headings = this.#scanHeadings();
    this.#ensureIds(headings);
    const depths = this.#computeDepths(headings);

    // Compute a key from id+depth to skip re-stamping when nothing
    // structurally changed (cheap idempotence).
    const key = headings.map((h, i) => `${h.id}:${depths[i]}`).join('|');
    if (key === this.#lastHeadingsKey && this.querySelector('nav')) {
      this.#setupObserver(headings);
      return;
    }
    this.#lastHeadingsKey = key;

    // Clear prior stamp (preserve any consumer-authored non-stamped
    // content by checking the data-stamped marker).
    const prior = this.querySelector('nav[data-stamped]');
    if (prior) prior.remove();

    if (headings.length === 0) {
      this.removeAttribute('aria-label');
      return;
    }

    const nav = document.createElement('nav');
    nav.setAttribute('data-stamped', '');
    nav.setAttribute('aria-label', this.label || 'Table of contents');

    const ul = document.createElement('ul');

    headings.forEach((h, i) => {
      const li = document.createElement('li');
      const a = document.createElement('a');
      a.setAttribute('href', `#${h.id}`);
      a.setAttribute('data-target-id', h.id);
      a.setAttribute('data-depth', String(depths[i]));
      a.textContent = (h.textContent || '').trim();
      li.appendChild(a);
      ul.appendChild(li);
    });

    nav.appendChild(ul);
    this.appendChild(nav);

    this.#setupObserver(headings);
  }

  #setupObserver(headings) {
    this.#observer?.disconnect();
    if (typeof IntersectionObserver === 'undefined') return;
    if (headings.length === 0) return;

    const offset = Number.isFinite(this.offset) ? this.offset : 80;
    this.#observer = new IntersectionObserver((entries) => {
      // Among entries currently intersecting, pick the one nearest the
      // top of the viewport (smallest boundingClientRect.top ≥ 0).
      const visible = entries
        .filter((e) => e.isIntersecting)
        .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
      if (visible.length === 0) return;
      this.#setActive(visible[0].target.id);
    }, {
      rootMargin: `-${offset}px 0px -50% 0px`,
      threshold: 0,
    });

    headings.forEach((h) => this.#observer.observe(h));
  }

  #setActive(id) {
    if (id === this.#activeId) return;
    this.#activeId = id;
    this.querySelectorAll('a[data-target-id]').forEach((a) => {
      if (a.dataset.targetId === id) a.setAttribute('data-active', '');
      else a.removeAttribute('data-active');
    });
    this.dispatchEvent(new CustomEvent('section-change', {
      bubbles: true,
      detail: { activeId: id },
    }));
  }

  connected() {
    super.connected();
    this.setAttribute('role', 'navigation');
    if (!this.hasAttribute('aria-label')) {
      this.setAttribute('aria-label', this.label || 'Table of contents');
    }
    // Defer scan to the next microtask so any sibling content stamped
    // by the same render pass has time to mount before we read its
    // heading structure.
    if (!this.#scheduledScan) {
      this.#scheduledScan = true;
      queueMicrotask(() => {
        this.#scheduledScan = false;
        this.#render();
      });
    }
  }

  render() {
    // Re-scan when target / headings / offset / label change. The
    // first render after connection is handled by the microtask in
    // connected(); this catches dynamic prop updates.
    if (this.#lastHeadingsKey || this.querySelector('nav[data-stamped]')) {
      this.#render();
    }
  }

  disconnected() {
    super.disconnected();
    this.#observer?.disconnect();
    this.#observer = null;
  }
}
