/**
 * AdiaUI Core — Light DOM web component base class.
 *
 * Re-exports reactivity and rendering for backward compatibility:
 *   import { UIElement, signal, computed, effect, html, repeat } from './element.js';
 *
 * Or import each layer directly:
 *   import { signal, computed, effect } from './signals.js';
 *   import { html, css, repeat } from './template.js';
 *   import { UIElement } from './element.js';
 */

// Re-export reactivity primitives
export { signal, computed, effect, batch, untracked, isSignal } from './signals.js';

// Re-export rendering
export { html, css, repeat } from './template.js';

// Internal imports for UIElement
import { signal, computed, effect, untracked } from './signals.js';
import { stamp, disposeParts, KEY_MAP } from './template.js';
import { getTrait } from '../traits/define.js';

// ═══════════════════════════════════════════════════════════════
// ADIA ELEMENT — Light DOM web component base class
// ═══════════════════════════════════════════════════════════════

const SIG = Symbol();
const CH = Symbol();

function installProps(el, props) {
  el[CH] = new Map();
  el[SIG] = new Map();
  for (const [key, cfg] of Object.entries(props)) {
    const attr = cfg.attribute ?? key.toLowerCase();
    const type = cfg.type ?? String;
    const sig = signal(cfg.default ?? undefined);
    el[SIG].set(key, sig);
    Object.defineProperty(el, key, {
      get() { return sig.value; },
      set(v) {
        const old = sig.peek();
        if (Object.is(old, v)) return;
        el[CH].set(key, old);
        sig.value = v;
        if (cfg.reflect) reflect(el, attr, v, type);
      },
      configurable: true,
    });
  }
}

function reflect(el, a, v, t) {
  t === Boolean
    ? v ? el.setAttribute(a, '') : el.removeAttribute(a)
    : v == null ? el.removeAttribute(a) : el.setAttribute(a, String(v));
}

const parseAttr = (v, t) =>
  t === Boolean ? v !== null : t === Number ? (v === null ? null : +v) : v;

function adoptStyles(ctor) {
  if (Object.hasOwn(ctor, '_sa')) return;
  ctor._sa = true;
  const sheets = ctor.styles;
  if (!sheets) return;
  const list = Array.isArray(sheets) ? sheets : [sheets];
  document.adoptedStyleSheets = [...document.adoptedStyleSheets, ...list.filter(s => !document.adoptedStyleSheets.includes(s))];
}

function prepareParts(ctor) {
  if (Object.hasOwn(ctor, '_pp')) return;
  ctor._pp = {};
  const parts = ctor.parts;
  if (!parts) return;
  for (const [slot, markup] of Object.entries(parts)) {
    const tpl = document.createElement('template');
    tpl.innerHTML = markup;
    ctor._pp[slot] = tpl.content.firstElementChild;
  }
}

export class UIElement extends HTMLElement {
  static get properties() { return {}; }
  static get traits() { return []; }
  static get observedAttributes() {
    const propAttrs = Object.entries(this.properties).map(([k, c]) => c.attribute ?? k.toLowerCase());
    // Always observe `traits` so HTML authors can declare composable behaviors:
    //   <comp-ui traits="pressable scale-press ripple">
    return propAttrs.includes('traits') ? propAttrs : [...propAttrs, 'traits'];
  }

  #fx = [];
  #tc = [];
  #as = [];
  #ctrl = null;
  #cu = null;
  #tf = null;
  #notify = signal(0);

  constructor() {
    super();
    this.internals = this.attachInternals();
    installProps(this, this.constructor.properties);
  }

  connectedCallback() {
    const ctor = this.constructor;
    if (!ctor._tag) ctor._tag = this.localName;
    adoptStyles(ctor);
    prepareParts(ctor);
    // connected() commonly reads this element's reactive properties via
    // template-literal interpolation (`${this.label}` etc.) to stamp its
    // inner DOM. If the outer call path is inside another effect (e.g.
    // a parent's render loop appendChild'ing this node), those reads
    // would subscribe the outer effect to THIS element's signals —
    // invisible cross-element coupling that looks like spooky re-runs
    // on unrelated state changes. Wrap in untracked so element setup
    // never leaks subscriptions upward. The element's OWN effect
    // (below) re-reads the same signals in its own tracking context,
    // so reactivity within the element is unaffected.
    untracked(() => this.connected());
    this.#fx.push(effect(() => {
      for (const sig of this[SIG].values()) sig.value;
      this.#notify.value;
      const result = ctor.template(this);
      if (result) stamp(result, this);
      this.render();
      if (this[CH].size) {
        const changed = new Map(this[CH]);
        this[CH].clear();
        this.updated(changed);
      }
    }, {
      host: this.localName,
      onError: this.onError ? (e) => this.onError(e) : null,
    }));
    if (!this.#tf) this.#tf = new Set();
    for (const t of ctor.traits) this.#applyTrait(t);
    this.#applyDeclaredTraits();
    if (this.#ctrl) this.#connectCtrl();
  }

  disconnectedCallback() {
    if (this._i) disposeParts(this._i.p);
    for (const d of this.#fx) d();
    this.#fx.length = 0;
    for (const s of this.#as) s.value = undefined;
    this.#as.length = 0;
    for (const t of this.#tc) t.disconnect(this);
    this.#tc.length = 0;
    this.#tf?.clear();
    this.#cu?.();
    this.#cu = null;
    this.#ctrl?.disconnect?.(this);
    this.disconnected();
  }

  #connectCtrl() {
    const c = this.#ctrl;
    c.connect?.(this);
    this.#cu = c.subscribe?.(() => { this.#notify.value++; });
  }

  get controller() { return this.#ctrl; }
  set controller(c) {
    this.#cu?.();
    this.#cu = null;
    this.#ctrl?.disconnect?.(this);
    this.#ctrl = c;
    if (c && this.isConnected) this.#connectCtrl();
  }

  #applyTrait(trait, { declarative = false } = {}) {
    this.#tf.add(trait);
    const instance = trait();
    instance.connect(this, { host: this, signal, computed, effect });
    if (declarative) instance._declarative = true;
    this.#tc.push(instance);
  }

  addTrait(trait) {
    if (!this.#tf) this.#tf = new Set();
    if (this.#tf.has(trait)) return this;
    this.#applyTrait(trait);
    return this;
  }

  /**
   * Parse the `traits` attribute and apply each named trait. Idempotent —
   * traits already attached via `static traits = [...]` or `addTrait()` are
   * skipped so an HTML author layering on top of the class declaration can't
   * double-apply.
   *
   * Unknown trait names log a one-line warning rather than throwing — this
   * happens in dev when an app forgot to import the trait barrel.
   */
  #applyDeclaredTraits() {
    const list = this.getAttribute('traits');
    if (!list) return;
    for (const name of list.split(/\s+/).filter(Boolean)) {
      const factory = getTrait(name);
      if (!factory) {
        console.warn(`<${this.localName}> traits="${name}" — trait not found. Did you forget to import it?`);
        continue;
      }
      if (this.#tf.has(factory)) continue;
      this.#applyTrait(factory, { declarative: true });
    }
  }

  /**
   * On `traits` attribute change, disconnect declarative trait instances
   * and re-apply from the new attribute value. Static + addTrait() instances
   * are preserved.
   */
  #refreshDeclaredTraits() {
    if (!this.#tf) return;
    // Remove instances flagged as declarative.
    const keep = [];
    for (const inst of this.#tc) {
      if (inst._declarative) {
        inst.disconnect(this);
        // Allow this factory to be re-applied via the new attribute value.
        for (const factory of this.#tf) {
          if (factory.schema?.name && inst.schema?.name === factory.schema.name) {
            this.#tf.delete(factory);
            break;
          }
        }
      } else {
        keep.push(inst);
      }
    }
    this.#tc.length = 0;
    this.#tc.push(...keep);
    this.#applyDeclaredTraits();
  }

  attributeChangedCallback(name, _, val) {
    if (name === 'traits') {
      if (this.isConnected) this.#refreshDeclaredTraits();
      return;
    }
    for (const [key, cfg] of Object.entries(this.constructor.properties)) {
      if ((cfg.attribute ?? key.toLowerCase()) === name) {
        this[key] = parseAttr(val, cfg.type ?? String);
        break;
      }
    }
  }

  static template = () => null;

  static create(props = {}) {
    const tag = this._tag ?? customElements.getName?.(this);
    if (!tag) throw new Error('Component not registered');
    const el = document.createElement(tag);
    for (const [k, v] of Object.entries(props)) el[k] = v;
    return el;
  }

  signal(v) { const s = signal(v); this.#as.push(s); return s; }

  /**
   * Get-or-create a structural part by slot name. Returns the existing
   * `[slot="X"]` child when present, otherwise clones the part blueprint
   * (`static parts`) and appends it. Re-stamped parts carry `_uiPart = true`
   * so a part wiped by a host `innerHTML` mutation is detectable downstream.
   *
   * INVARIANT (FEEDBACK-31) — call `ensure()` from `render()`; never cache
   * its result once in `connected()`. A consumer that replaces the host's
   * children (`el.innerHTML = …`) wipes stamped parts; `render()` re-stamps
   * them, but a reference captured once in `connected()` is left pointing at
   * detached DOM. Part identity does not survive a host child-list wipe.
   */
  ensure(slot) {
    for (const ch of this.children) {
      if (ch.getAttribute('slot') === slot) return ch;
    }
    const deep = this.#findOwnSlot(slot);
    if (deep) return deep;
    prepareParts(this.constructor);
    const blueprint = this.constructor._pp?.[slot];
    if (!blueprint) return null;
    const el = blueprint.cloneNode(true);
    el._uiPart = true;
    this.appendChild(el);
    return el;
  }

  drop(slot) {
    for (const ch of this.children) {
      if (ch.getAttribute('slot') === slot) { ch.remove(); return; }
    }
    const deep = this.#findOwnSlot(slot);
    if (deep) deep.remove();
  }

  #findOwnSlot(slot) {
    for (const found of this.querySelectorAll(`[slot="${slot}"]`)) {
      let el = found.parentElement, owned = true;
      while (el && el !== this) {
        if (el.localName.includes('-')) { owned = false; break; }
        el = el.parentElement;
      }
      if (owned && el === this) return found;
    }
    return null;
  }

  reconcile(parent, items, keyFn, stampFn) {
    const map = parent[KEY_MAP] || new Map();
    const newMap = new Map();
    const order = [];
    for (let i = 0; i < items.length; i++) {
      const item = items[i];
      const key = keyFn(item, i);
      if (newMap.has(key)) continue;
      let el = map.get(key);
      if (el) { stampFn(item, i, el); }
      else { el = stampFn(item, i, null); }
      newMap.set(key, el);
      order.push(key);
    }
    for (const [key, el] of map) { if (!newMap.has(key)) el.remove(); }
    let ref = null;
    for (let i = order.length - 1; i >= 0; i--) {
      const el = newMap.get(order[i]);
      if (el.nextSibling !== ref || el.parentNode !== parent)
        parent.insertBefore(el, ref);
      ref = el;
    }
    parent[KEY_MAP] = newMap;
  }

  connected() {}
  render() {}
  disconnected() {}
  updated(_changed) {}
}
