import { describe, it, expect, beforeEach } from 'vitest';
import { UIElement, signal, effect } from './element.js';

// ── Shared helpers ──

let registrationCounter = 0;
function registerTestElement(BaseImpl) {
  const tag = `test-el-${++registrationCounter}`;
  customElements.define(tag, BaseImpl);
  return tag;
}

function mount(tag, attrs = {}) {
  const el = document.createElement(tag);
  for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
  document.body.appendChild(el);
  return el;
}

// Signals flush via queueMicrotask — await one tick to let reactive
// effects settle after a mutation.
const tick = () => new Promise((r) => queueMicrotask(r));

describe('UIElement — construction + lifecycle', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('instantiates and calls connected() on mount, disconnected() on remove', () => {
    const events = [];
    class El extends UIElement {
      connected()    { events.push('connected'); }
      disconnected() { events.push('disconnected'); }
    }
    const tag = registerTestElement(El);
    const el = mount(tag);
    expect(events).toEqual(['connected']);
    el.remove();
    expect(events).toEqual(['connected', 'disconnected']);
  });

  it('remount after disconnect re-runs connected()', () => {
    const events = [];
    class El extends UIElement {
      connected()    { events.push('connected'); }
      disconnected() { events.push('disconnected'); }
    }
    const tag = registerTestElement(El);
    const el = mount(tag);
    el.remove();
    document.body.appendChild(el);
    expect(events).toEqual(['connected', 'disconnected', 'connected']);
  });
});

describe('UIElement — properties', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('reads default value from static properties', () => {
    class El extends UIElement {
      static properties = { label: { type: String, default: 'hi' } };
    }
    const tag = registerTestElement(El);
    const el = mount(tag);
    expect(el.label).toBe('hi');
  });

  // FEEDBACK-99 (color-app, 2026-06-07): a property assigned on the instance
  // BEFORE upgrade — template engines set `.prop=${v}` during stamp, possibly
  // before the element is defined (see template.js property-binding path) —
  // must survive upgrade, not be clobbered by the signal default. WHATWG
  // "upgrade a property" pattern, here in installProps. Fixes input-ui `.value`.
  it('reifies a property set on the instance before upgrade (lazy upgrade)', () => {
    const tag = `test-el-${++registrationCounter}`;
    const el = document.createElement(tag);   // undefined tag → un-upgraded
    document.body.appendChild(el);
    el.value = 'pre-upgrade';                  // own data-property, no accessor yet
    class El extends UIElement {
      static properties = { value: { type: String, default: '' } };
    }
    customElements.define(tag, El);            // upgrade → installProps runs
    expect(el.value).toBe('pre-upgrade');      // reified, not clobbered to ''
  });

  it('reflects Boolean properties to HTML attributes when reflect: true', () => {
    class El extends UIElement {
      static properties = { disabled: { type: Boolean, default: false, reflect: true } };
    }
    const tag = registerTestElement(El);
    const el = mount(tag);
    expect(el.hasAttribute('disabled')).toBe(false);
    el.disabled = true;
    expect(el.hasAttribute('disabled')).toBe(true);
    el.disabled = false;
    expect(el.hasAttribute('disabled')).toBe(false);
  });

  it('reflects String properties as attribute values', () => {
    class El extends UIElement {
      static properties = { variant: { type: String, default: 'neutral', reflect: true } };
    }
    const tag = registerTestElement(El);
    const el = mount(tag);
    el.variant = 'accent';
    expect(el.getAttribute('variant')).toBe('accent');
  });

  it('parses incoming attributes back into typed properties', () => {
    class El extends UIElement {
      static properties = {
        count:    { type: Number,  default: 0 },
        active:   { type: Boolean, default: false },
      };
    }
    const tag = registerTestElement(El);
    const el = mount(tag, { count: '42', active: '' });
    expect(el.count).toBe(42);
    expect(el.active).toBe(true);
  });

  it('does NOT reflect when reflect: false / omitted', () => {
    class El extends UIElement {
      static properties = { internal: { type: String, default: 'x' } };
    }
    const tag = registerTestElement(El);
    const el = mount(tag);
    el.internal = 'y';
    expect(el.hasAttribute('internal')).toBe(false);
  });
});

describe('UIElement — reactive effect', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('calls render() on mount and when a reactive property changes', async () => {
    let renders = 0;
    class El extends UIElement {
      static properties = { label: { type: String, default: 'a' } };
      render() { renders++; void this.label; }
    }
    const tag = registerTestElement(El);
    const el = mount(tag);
    expect(renders).toBeGreaterThan(0);
    const before = renders;
    el.label = 'b';
    await tick();
    expect(renders).toBeGreaterThan(before);
  });

  it('stops re-rendering after disconnect', async () => {
    let renders = 0;
    class El extends UIElement {
      static properties = { label: { type: String, default: 'a' } };
      render() { renders++; void this.label; }
    }
    const tag = registerTestElement(El);
    const el = mount(tag);
    el.remove();
    const frozen = renders;
    el.label = 'c';
    await tick();
    expect(renders).toBe(frozen);
  });
});

describe('UIElement — subscription-leak guard (0.0.10 regression)', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('child connected() reads do NOT subscribe an outer effect', async () => {
    // The bug: when a parent effect called appendChild(child) while running,
    // child.connectedCallback would synchronously call connected(), and any
    // template-literal reads of child's reactive properties would subscribe
    // the OUTER effect to the CHILD's signals. A later change to a child
    // prop would spuriously re-run the parent effect.
    //
    // Fix: connected() is now wrapped in untracked(…).
    class Child extends UIElement {
      static properties = { value: { type: String, default: 'initial', reflect: true } };
      connected() {
        // Read our own reactive prop exactly the way template-literal
        // interpolation would — this read must not subscribe an ancestor
        // effect.
        void this.value;
      }
    }
    const tag = registerTestElement(Child);

    const trigger = signal(0);
    let outerRuns = 0;
    let childRef = null;

    const dispose = effect(() => {
      outerRuns++;
      void trigger.value;
      if (!childRef) {
        childRef = document.createElement(tag);
        document.body.appendChild(childRef);
      }
    });

    const outerRunsAfterMount = outerRuns;
    expect(childRef).not.toBeNull();

    // Now mutate the CHILD's signal — the outer effect MUST NOT re-run.
    childRef.value = 'changed';
    await tick();
    expect(outerRuns).toBe(outerRunsAfterMount);

    // Sanity check: mutating the trigger signal the outer effect DID
    // subscribe to re-runs the effect, proving the effect is alive.
    trigger.value = 1;
    await tick();
    expect(outerRuns).toBe(outerRunsAfterMount + 1);

    dispose();
  });
});

describe('UIElement — addTrait', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('applies a trait once, idempotent on repeat', () => {
    let connects = 0;
    const myTrait = () => ({
      connect() { connects++; },
      disconnect() {},
    });

    class El extends UIElement {}
    const tag = registerTestElement(El);
    const el = mount(tag);
    el.addTrait(myTrait);
    el.addTrait(myTrait);
    expect(connects).toBe(1);
  });

  it('disconnects all applied traits on remove', () => {
    let connects = 0, disconnects = 0;
    const myTrait = () => ({
      connect()    { connects++; },
      disconnect() { disconnects++; },
    });
    class El extends UIElement {
      static traits = [myTrait];
    }
    const tag = registerTestElement(El);
    const el = mount(tag);
    expect(connects).toBe(1);
    expect(disconnects).toBe(0);
    el.remove();
    expect(disconnects).toBe(1);
  });
});

// gh#285 — SSR DOM shims (linkedom et al.) implement no ElementInternals
// and no document.adoptedStyleSheets. These tests simulate that absence
// directly (deleting the real APIs) rather than trusting a mock — the
// bug was an unconditional call crashing the CONSTRUCTOR, before any
// subclass code ran, so the only real proof is exercising construction
// with the API genuinely gone.
describe('UIElement — SSR browser-API absence (gh#285)', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('constructs without throwing when attachInternals does not exist', () => {
    const original = HTMLElement.prototype.attachInternals;
    delete HTMLElement.prototype.attachInternals;
    try {
      class El extends UIElement {}
      const tag = registerTestElement(El);
      expect(() => mount(tag)).not.toThrow();
    } finally {
      HTMLElement.prototype.attachInternals = original;
    }
  });

  it('the no-op internals shim answers every call site the framework actually makes', () => {
    const original = HTMLElement.prototype.attachInternals;
    delete HTMLElement.prototype.attachInternals;
    let el;
    try {
      class El extends UIElement {}
      const tag = registerTestElement(El);
      el = mount(tag);
    } finally {
      HTMLElement.prototype.attachInternals = original;
    }
    expect(() => el.internals.setFormValue('x')).not.toThrow();
    expect(() => el.internals.setValidity({})).not.toThrow();
    expect(el.internals.checkValidity()).toBe(true);
    expect(el.internals.reportValidity()).toBe(true);
    expect(el.internals.form).toBeNull();
    expect(el.internals.labels).toEqual([]);
    expect(el.internals.validity).toEqual({});
    expect(el.internals.validationMessage).toBe('');
    expect(el.internals.willValidate).toBe(false);
  });

  it('a real attachInternals is still used when the browser provides one (no regression)', () => {
    class El extends UIElement {}
    const tag = registerTestElement(El);
    const el = mount(tag);
    // happy-dom's real attachInternals — not the shim.
    expect(el.internals).not.toBe(null);
    expect(typeof el.internals.setFormValue).toBe('function');
  });

  it('adoptStyles no-ops instead of throwing when adoptedStyleSheets does not exist', () => {
    const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'adoptedStyleSheets');
    delete Document.prototype.adoptedStyleSheets;
    try {
      class El extends UIElement {
        static styles = ['/* fake sheet */'];
      }
      const tag = registerTestElement(El);
      expect(() => mount(tag)).not.toThrow();
    } finally {
      if (desc) Object.defineProperty(Document.prototype, 'adoptedStyleSheets', desc);
    }
  });
});

// gh#284 — the custom-elements spec's "upgrade an element" algorithm
// (§4.13.5 step 6) requires replaying attributeChangedCallback for every
// attribute already present on an element BEFORE connectedCallback fires
// on upgrade. happy-dom (this repo's own test DOM) skips that replay —
// confirmed directly: a bare custom element upgraded with a pre-existing
// observed attribute gets connectedCallback but never the matching
// attributeChangedCallback. linkedom (the real SSR consumer's DOM shim,
// per adia-ssr) is a similarly from-scratch custom-elements registry, so
// the same gap is expected there too. Simulating the absence directly
// (a real late-upgrade sequence, not a mock) rather than trusting that
// happy-dom already reproduces the SSR shape — it does, which is exactly
// how this was found.
describe('UIElement — SSR attribute-upgrade replay (gh#284)', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  // Parses markup into an UNDEFINED custom element (so its attributes land
  // on the element the way a server-rendered / pre-parsed document would),
  // THEN defines the tag — matching the "define after parse" shape SSR
  // hydration and lazy client registration both produce.
  function mountLateUpgrade(tag, BaseImpl, html) {
    const container = document.createElement('div');
    container.innerHTML = html;
    document.body.appendChild(container);
    customElements.define(tag, BaseImpl);
    return container.querySelector(tag);
  }

  it('confirms the environment gap this fix compensates for', () => {
    const tag = `test-plain-${++registrationCounter}`;
    const calls = [];
    class Plain extends HTMLElement {
      static get observedAttributes() { return ['foo']; }
      attributeChangedCallback(name, oldV, newV) { calls.push([name, oldV, newV]); }
      connectedCallback() { calls.push(['connected']); }
    }
    mountLateUpgrade(tag, Plain, `<${tag} foo="bar"></${tag}>`);
    expect(calls).toEqual([['connected']]); // NOT ['foo', null, 'bar'] first, per spec
  });

  it('a reflected property set only via pre-parsed HTML is not stuck at its class default', () => {
    const tag = `test-late-${++registrationCounter}`;
    class El extends UIElement {
      static properties = { label: { type: String, default: '', reflect: true } };
    }
    const el = mountLateUpgrade(tag, El, `<${tag} label="Profile"></${tag}>`);
    expect(el.label).toBe('Profile');
  });

  it('a reflected boolean attribute present-with-no-value resolves true, not the class default', () => {
    const tag = `test-late-bool-${++registrationCounter}`;
    class El extends UIElement {
      static properties = { active: { type: Boolean, default: false, reflect: true } };
    }
    const el = mountLateUpgrade(tag, El, `<${tag} active></${tag}>`);
    expect(el.active).toBe(true);
  });

  it('no regression: normal define-before-parse still gets attributeChangedCallback (no extra render)', async () => {
    let renders = 0;
    class El extends UIElement {
      static properties = { label: { type: String, default: '', reflect: true } };
      render() { renders++; }
    }
    const tag = registerTestElement(El);
    const el = mount(tag, { label: 'Profile' });
    await tick();
    expect(el.label).toBe('Profile');
    expect(renders).toBe(1); // the connectedCallback re-sync is a no-op here, not a second render
  });
});
