/**
 * <swatch-ui> behavioral tests.
 *
 * Currently scoped to FB-36 / §325 regression coverage. Future tests for
 * #stamp() lifecycle, chrome-slot composition, auto-contrast probe,
 * etc. land here.
 *
 * Test setup note: happy-dom connects custom elements synchronously
 * during innerHTML parsing — BEFORE sibling children inside the same
 * element are appended. Real browsers connect after the full innerHTML
 * is parsed. To exercise the FB-36 path (children present at connect
 * time), tests use `document.createElement` + `appendChild` to build
 * the full subtree FIRST, then attach to the document — guaranteeing
 * the children are in `this.childNodes` when `connectedCallback()` fires.
 */

import { describe, it, expect, beforeAll, beforeEach } from 'vitest';

beforeAll(async () => {
  await import('./swatch.js');
});

function buildSwatch({ label, defaultSlot, shape = 'block', labelPosition = 'overlay', color = '#3b82f6' }) {
  const swatch = document.createElement('swatch-ui');
  swatch.setAttribute('shape', shape);
  swatch.setAttribute('label-position', labelPosition);
  swatch.setAttribute('color', color);
  if (label !== undefined) swatch.setAttribute('label', label);
  if (defaultSlot) {
    // Mimic indented HTML's whitespace text nodes — the FB-36 trigger.
    swatch.appendChild(document.createTextNode('\n      '));
    const child = document.createElement('span');
    child.id = defaultSlot.id;
    child.textContent = defaultSlot.text;
    swatch.appendChild(child);
    swatch.appendChild(document.createTextNode('\n    '));
  }
  return swatch;
}

describe('<swatch-ui> default-slot + label-attr precedence (FB-36 / §325)', () => {
  let host;

  beforeEach(() => {
    host = document.createElement('div');
    document.body.appendChild(host);
  });

  it('preserves default-slot element when label="X" is also present (FB-36 §325 fix)', async () => {
    const swatch = buildSwatch({
      label: '500',
      defaultSlot: { id: 'probe-default', text: 'richlabel' },
    });
    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 80));

    const label = swatch.querySelector('[data-label]');
    const span = swatch.querySelector('#probe-default');

    // Pre-§325 the span was wiped + label="500" took precedence.
    // Post-§325 the default-slot wins (yaml spec: "richer content").
    expect(span).not.toBeNull();
    expect(label.textContent.trim()).toBe('richlabel');
  });

  it('renders label-attr text when no default-slot is present', async () => {
    const swatch = buildSwatch({ label: '500' });
    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 80));
    const label = swatch.querySelector('[data-label]');
    expect(label.textContent.trim()).toBe('500');
  });

  it('renders default-slot text when no label-attr is present', async () => {
    const swatch = buildSwatch({
      defaultSlot: { id: 'probe-only', text: 'defaultonly' },
    });
    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 80));
    const label = swatch.querySelector('[data-label]');
    const span = swatch.querySelector('#probe-only');
    expect(span).not.toBeNull();
    expect(label.textContent.trim()).toBe('defaultonly');
  });

  it('syncs label-attr changes when there is no default-slot (no whitespace regression)', async () => {
    const swatch = buildSwatch({ label: '500' });
    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 80));
    swatch.label = '600';
    await new Promise((r) => setTimeout(r, 80));
    const label = swatch.querySelector('[data-label]');
    expect(label.textContent.trim()).toBe('600');
  });
});

describe('<swatch-ui> late-arriving [slot="chrome"] children (FB-37 / §326)', () => {
  let host;

  beforeEach(() => {
    host = document.createElement('div');
    document.body.appendChild(host);
  });

  it('absorbs chrome child appended AFTER connectedCallback (template-render path)', async () => {
    // Mimic the template-engine timing: create the swatch + connect it FIRST
    // (children empty), then append the chrome child + trigger a render.
    const swatch = document.createElement('swatch-ui');
    swatch.setAttribute('shape', 'block');
    swatch.setAttribute('label-position', 'overlay');
    swatch.setAttribute('color', '#3b82f6');
    swatch.setAttribute('label', '500');
    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 80));

    // At this point #stamp() has run + chromeSlot was empty.
    // Append a chrome child LATER — this is what template engines do.
    const chrome = document.createElement('span');
    chrome.setAttribute('slot', 'chrome');
    chrome.id = 'late-chrome';
    chrome.textContent = '★';
    swatch.appendChild(chrome);

    // Trigger a re-render via property mutation (template engines typically
    // set props after appending — this nudge fires render → #syncCore →
    // #absorbChromeSlot picks up the late chrome child). Must use a value
    // DIFFERENT from current to actually fire the signal write.
    swatch.label = '999';
    await new Promise((r) => setTimeout(r, 80));

    const found = swatch.querySelector('#late-chrome');
    expect(found).not.toBeNull();
    // Chrome child should be a direct host sibling positioned between tile + badge.
    const tile = swatch.querySelector('[data-tile]');
    const badge = swatch.querySelector('[data-badge]');
    expect(found.previousElementSibling).toBe(tile);
    expect(found.nextElementSibling).toBe(badge);
  });

  it('still handles chrome child appended BEFORE connect (static-HTML path)', async () => {
    // Build swatch with chrome child present at connect time — the canonical
    // FB-34 / §314 path should still work post-§326.
    const swatch = document.createElement('swatch-ui');
    swatch.setAttribute('shape', 'block');
    swatch.setAttribute('label-position', 'overlay');
    swatch.setAttribute('color', '#3b82f6');
    swatch.setAttribute('label', '500');
    const chrome = document.createElement('span');
    chrome.setAttribute('slot', 'chrome');
    chrome.id = 'early-chrome';
    chrome.textContent = '⚑';
    swatch.appendChild(chrome);
    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 80));

    const found = swatch.querySelector('#early-chrome');
    expect(found).not.toBeNull();
    const tile = swatch.querySelector('[data-tile]');
    const badge = swatch.querySelector('[data-badge]');
    expect(found.previousElementSibling).toBe(tile);
    expect(found.nextElementSibling).toBe(badge);
  });
});

describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 / §331)', () => {
  let host;

  beforeEach(() => {
    host = document.createElement('div');
    document.body.appendChild(host);
  });

  it('hoists chrome child from inside wrapper span at #stamp() time (template-interpolation path)', async () => {
    // Simulates AdiaUI's html`` template wrapping interpolated content in
    // <span style="display: contents"> (per core/template.js:212 `wrap()`).
    // When consumer writes `html`<swatch-ui>${chromeFrag}</swatch-ui>``,
    // the chromeFrag's content lands NESTED inside a wrapper span — not
    // as direct children of <swatch-ui>. Pre-§331 the chrome was wiped;
    // §341 (FB-43) refined to keep chrome INSIDE the wrapper + move the
    // wrapper to canonical position (preserves template-engine `part.n`
    // identity so parent re-renders update in-place).
    const swatch = document.createElement('swatch-ui');
    swatch.setAttribute('shape', 'block');
    swatch.setAttribute('label-position', 'overlay');
    swatch.setAttribute('color', '#3b82f6');
    swatch.setAttribute('label', '500');

    const wrapper = document.createElement('span');
    wrapper.style.display = 'contents';
    const chrome = document.createElement('span');
    chrome.setAttribute('slot', 'chrome');
    chrome.id = 'wrapped-chrome';
    chrome.textContent = 'P3';
    wrapper.appendChild(chrome);
    swatch.appendChild(wrapper);

    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 80));

    const found = swatch.querySelector('#wrapped-chrome');
    expect(found).not.toBeNull();
    // §341: chrome stays inside the display:contents wrapper; wrapper is
    // at canonical position (between tile + badge). Visually equivalent
    // to direct-child placement because display:contents collapses the box.
    expect(found.parentElement).toBe(wrapper);
    expect(wrapper.parentElement).toBe(swatch);
    const tile = swatch.querySelector('[data-tile]');
    const badge = swatch.querySelector('[data-badge]');
    expect(wrapper.previousElementSibling).toBe(tile);
    expect(wrapper.nextElementSibling).toBe(badge);
  });

  it('handles MULTIPLE chrome children nested in separate wrapper spans', async () => {
    // Each interpolated fragment in html`` gets its own wrapper span,
    // so multi-chrome usage (`${badge}${dot1}${dot2}`) produces multiple
    // wrapper spans, each containing one chrome child. §341: wrappers
    // stay intact, chrome remains inside them.
    const swatch = document.createElement('swatch-ui');
    swatch.setAttribute('shape', 'block');
    swatch.setAttribute('label-position', 'overlay');
    swatch.setAttribute('color', '#3b82f6');

    const ids = ['c1', 'c2', 'c3'];
    for (const id of ids) {
      const w = document.createElement('span');
      w.style.display = 'contents';
      const c = document.createElement('span');
      c.setAttribute('slot', 'chrome');
      c.id = id;
      c.textContent = id;
      w.appendChild(c);
      swatch.appendChild(w);
    }
    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 80));

    for (const id of ids) {
      const found = swatch.querySelector(`#${id}`);
      expect(found).not.toBeNull();
      // §341: chrome stays inside its wrapper; wrapper is a direct child of swatch.
      expect(found.parentElement.tagName).toBe('SPAN');
      expect(found.parentElement.style.display).toBe('contents');
      expect(found.parentElement.parentElement).toBe(swatch);
    }
  });

  it('hoists chrome from late-arriving wrapper span (post-stamp template re-render)', async () => {
    // Parent template re-render after the swatch is already stamped:
    // a new wrapper span carrying chrome content arrives after #stamp().
    // #absorbChromeSlot() should pick it up on next render pass.
    const swatch = document.createElement('swatch-ui');
    swatch.setAttribute('shape', 'block');
    swatch.setAttribute('color', '#3b82f6');
    swatch.setAttribute('label', '500');
    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 80));

    // Late: append a wrapper-span carrying chrome
    const wrapper = document.createElement('span');
    wrapper.style.display = 'contents';
    const chrome = document.createElement('span');
    chrome.setAttribute('slot', 'chrome');
    chrome.id = 'late-wrapped';
    wrapper.appendChild(chrome);
    swatch.appendChild(wrapper);

    // Nudge a render via a property change
    swatch.label = '600';
    await new Promise((r) => setTimeout(r, 80));

    const found = swatch.querySelector('#late-wrapped');
    expect(found).not.toBeNull();
    // §341: chrome stays inside wrapper; wrapper is now at canonical
    // position (between tile and badge) after #absorbChromeSlot()'s
    // re-positioning of the wrapper.
    expect(found.parentElement).toBe(wrapper);
    expect(wrapper.parentElement).toBe(swatch);
    const tile = swatch.querySelector('[data-tile]');
    const badge = swatch.querySelector('[data-badge]');
    expect(wrapper.previousElementSibling).toBe(tile);
    expect(wrapper.nextElementSibling).toBe(badge);
  });
});

describe('<swatch-ui> [auto-contrast] does not override [label-position="overlay"] position (FB-41 / §339)', () => {
  // Structural CSS-source-string assertion: happy-dom does not resolve
  // @scope CSS specificity reliably, so we verify the fix by reading
  // swatch.css text and asserting the auto-contrast rules don't carry
  // `position: relative` (which collided at equal specificity with the
  // overlay rule's `position: absolute` via source order).
  //
  // Pre-§339: `:scope[shape="block"][auto-contrast] > [data-label]`
  // declared `position: relative; z-index: 1`. Combined with overlay
  // attribute, the later rule won → label dropped out of overlay layout.
  //
  // Post-§339: only `z-index: 1` remains (z-index is no-op on static
  // elements but harmless; for the overlay case it stacks above the
  // tile correctly via the overlay rule's `position: absolute`).
  it('auto-contrast label rule does NOT declare position: relative (regression for FB-41)', async () => {
    const fs = await import('node:fs');
    const path = await import('node:path');
    const url = await import('node:url');
    const here = path.dirname(url.fileURLToPath(import.meta.url));
    const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
    const match = css.match(
      /:scope\[shape="block"\]\[auto-contrast\]\s*>\s*\[data-label\]\s*\{([^}]*)\}/
    );
    expect(match).not.toBeNull();
    const block = match[1];
    expect(block).not.toMatch(/position\s*:\s*relative/);
    expect(block).toMatch(/z-index\s*:\s*1/);
  });

  it('auto-contrast detail rule does NOT declare position: relative (regression for FB-41)', async () => {
    const fs = await import('node:fs');
    const path = await import('node:path');
    const url = await import('node:url');
    const here = path.dirname(url.fileURLToPath(import.meta.url));
    const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
    const match = css.match(
      /:scope\[shape="block"\]\[auto-contrast\]\s*>\s*\[data-detail\]\s*\{([^}]*)\}/
    );
    expect(match).not.toBeNull();
    const block = match[1];
    expect(block).not.toMatch(/position\s*:\s*relative/);
    expect(block).toMatch(/z-index\s*:\s*1/);
  });

  it('overlay rule still declares position: absolute (regression for FB-41)', async () => {
    // Defensive: also assert the overlay rule's `position: absolute` is
    // still in place — if a future refactor drops it, the §253 contract
    // breaks regardless of the auto-contrast collision.
    const fs = await import('node:fs');
    const path = await import('node:path');
    const url = await import('node:url');
    const here = path.dirname(url.fileURLToPath(import.meta.url));
    const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
    const match = css.match(
      /:scope\[shape="block"\]\[label-position="overlay"\]\s*>\s*\[data-label\]\s*\{([^}]*)\}/
    );
    expect(match).not.toBeNull();
    const block = match[1];
    expect(block).toMatch(/position\s*:\s*absolute/);
  });
});

describe('<swatch-ui> chrome survives parent template re-render (FB-43 / §341)', () => {
  // FB-43 (P1): claim is `template.js` mount() replaceChildren() destroys
  // chrome children on every re-render. The real mechanism is more subtle:
  // §331's #stamp() does `innerHTML = ''` which wipes the wrapper-span
  // created by the parent template engine's wrap() (after collecting
  // chrome into chromeSlot). The parent template's `part.n` still points
  // to the now-detached wrapper-span. On parent re-render, applyValue()
  // stamps the new chrome value INTO the detached wrapper-span — landing
  // in memory but not in the DOM. Chrome is effectively destroyed.
  //
  // This empirical test reproduces the exact scenario.
  let host;
  let html, stamp;

  beforeAll(async () => {
    ({ html, stamp } = await import('../../core/template.js'));
  });

  beforeEach(() => {
    host = document.createElement('div');
    document.body.appendChild(host);
  });

  // §341 contract: chrome is queryable from swatch + lives inside a
  // display:contents wrapper-span (the template-engine's stable part.n).
  // The wrapper sits at canonical chrome position (between tile + badge).
  // Visually equivalent to direct-child placement because display:contents
  // collapses the wrapper's box.
  function assertChromeCanonical(swatch, chrome) {
    expect(chrome).not.toBeNull();
    const wrapper = chrome.parentElement;
    expect(wrapper).not.toBeNull();
    // The wrapper is the chrome's direct parent + a display:contents span
    // that's a direct child of the swatch (the template-engine's
    // wrap()-created stable reference).
    expect(wrapper.tagName).toBe('SPAN');
    expect(wrapper.style.display).toBe('contents');
    expect(wrapper.parentElement).toBe(swatch);
    // Wrapper sits between tile and badge (canonical chrome region).
    const tile = swatch.querySelector('[data-tile]');
    const badge = swatch.querySelector('[data-badge]');
    expect(tile).not.toBeNull();
    expect(badge).not.toBeNull();
    // Walk from tile forward: should hit wrapper before badge.
    let cursor = tile.nextElementSibling;
    let foundWrapper = false;
    while (cursor && cursor !== badge) {
      if (cursor === wrapper) { foundWrapper = true; break; }
      cursor = cursor.nextElementSibling;
    }
    expect(foundWrapper).toBe(true);
  }

  it('FB-43: chrome interpolated via html`` template survives first render', async () => {
    const chromeFrag = html`<span slot="chrome" id="fb43-chrome">P3</span>`;
    const tpl = html`<swatch-ui label="500" color="#3b82f6">${chromeFrag}</swatch-ui>`;
    stamp(tpl, host);
    await new Promise((r) => setTimeout(r, 80));

    const found = host.querySelector('#fb43-chrome');
    expect(found).not.toBeNull();
    const swatch = host.querySelector('swatch-ui');
    assertChromeCanonical(swatch, found);
  });

  it('FB-43: chrome interpolated via html`` template survives second parent re-stamp (same strings)', async () => {
    // FB-43's core claim: re-render destroys chrome. Same template strings
    // → stamp() cache should hit → update() runs. Chrome content updates
    // in-place inside the preserved wrapper-span.
    function renderOnce(chromeText) {
      const chromeFrag = html`<span slot="chrome" id="fb43-chrome">${chromeText}</span>`;
      return html`<swatch-ui label="500" color="#3b82f6">${chromeFrag}</swatch-ui>`;
    }
    stamp(renderOnce('P3'), host);
    await new Promise((r) => setTimeout(r, 80));
    expect(host.querySelector('#fb43-chrome')).not.toBeNull();

    // Re-stamp with same template strings (different value).
    stamp(renderOnce('OOG'), host);
    await new Promise((r) => setTimeout(r, 80));

    const swatch = host.querySelector('swatch-ui');
    const chrome = swatch.querySelector('[slot="chrome"]');
    expect(chrome).not.toBeNull();
    expect(chrome.textContent).toBe('OOG');
    assertChromeCanonical(swatch, chrome);
  });

  it('FB-43: chrome survives N consecutive re-renders (regression stress test)', async () => {
    function renderOnce(chromeText) {
      const chromeFrag = html`<span slot="chrome" id="fb43-chrome">${chromeText}</span>`;
      return html`<swatch-ui label="500" color="#3b82f6">${chromeFrag}</swatch-ui>`;
    }
    for (let i = 0; i < 5; i++) {
      stamp(renderOnce(`label-${i}`), host);
      await new Promise((r) => setTimeout(r, 60));
    }
    const swatch = host.querySelector('swatch-ui');
    const chrome = swatch.querySelector('[slot="chrome"]');
    expect(chrome).not.toBeNull();
    expect(chrome.textContent).toBe('label-4');
    assertChromeCanonical(swatch, chrome);
  });
});

describe('<swatch-ui> chrome absorbed on late arrival (FB-44 / §-TBD)', () => {
  // FB-44 (P1): #absorbChromeSlot() runs from #syncCore() which only fires
  // on attribute/property changes. scan() walks element.attributes BEFORE
  // descending to child nodes, so the parent template's update() loop
  // assigns properties (→ render() → #syncCore() → #absorbChromeSlot())
  // BEFORE interpolating child node parts. The once-only absorb pass sees
  // an empty children set; chrome lands moments later at the arrival
  // position, not the canonical post-tile region.
  //
  // §-TBD adds a MutationObserver on host childList that re-runs
  // #absorbChromeSlot() when chrome (direct or wrapper-spanned) arrives.
  let host;

  beforeEach(() => {
    host = document.createElement('div');
    document.body.appendChild(host);
  });

  // Inline a minimal canonical-position assertion (same shape as the
  // FB-43 helper but local to this describe block).
  function assertChromePositionedBeforeBadge(swatch, chrome) {
    expect(chrome).not.toBeNull();
    const badge = swatch.querySelector('[data-badge]');
    expect(badge).not.toBeNull();
    // The chrome (or its wrapper-span ancestor) should be a child of swatch
    // that appears before the badge.
    let chromeChild = chrome;
    while (chromeChild.parentElement !== swatch && chromeChild.parentElement) {
      chromeChild = chromeChild.parentElement;
    }
    expect(chromeChild.parentElement).toBe(swatch);
    // Walk siblings from chromeChild forward — badge must be reachable.
    let cursor = chromeChild;
    let foundBadge = false;
    while (cursor) {
      if (cursor === badge) { foundBadge = true; break; }
      cursor = cursor.nextElementSibling;
    }
    expect(foundBadge).toBe(true);
  }

  it('FB-44: chrome appended via host.appendChild() after connect lands at canonical position', async () => {
    const swatch = document.createElement('swatch-ui');
    swatch.setAttribute('shape', 'block');
    swatch.setAttribute('color', '#3b82f6');
    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 30));

    // Append chrome AFTER initial #stamp() + #syncCore() pass completed.
    const badge = document.createElement('span');
    badge.setAttribute('slot', 'chrome');
    badge.id = 'fb44-late-chrome';
    badge.textContent = 'P3';
    swatch.appendChild(badge);
    // Wait a microtask for the MutationObserver to fire.
    await new Promise((r) => setTimeout(r, 30));

    const found = swatch.querySelector('#fb44-late-chrome');
    assertChromePositionedBeforeBadge(swatch, found);
  });

  it('FB-44: chrome appended via display:contents wrapper-span after connect lands at canonical position', async () => {
    const swatch = document.createElement('swatch-ui');
    swatch.setAttribute('shape', 'block');
    swatch.setAttribute('color', '#3b82f6');
    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 30));

    // Simulate the template engine's wrap() output: display:contents span
    // carrying the chrome child as its only descendant.
    const wrapper = document.createElement('span');
    wrapper.style.display = 'contents';
    const badge = document.createElement('span');
    badge.setAttribute('slot', 'chrome');
    badge.id = 'fb44-wrapped-chrome';
    badge.textContent = 'OOG';
    wrapper.appendChild(badge);
    swatch.appendChild(wrapper);
    await new Promise((r) => setTimeout(r, 30));

    const found = swatch.querySelector('#fb44-wrapped-chrome');
    assertChromePositionedBeforeBadge(swatch, found);
  });

  it('FB-44: chrome observer is disconnected when host leaves the DOM (leak regression)', async () => {
    const swatch = document.createElement('swatch-ui');
    swatch.setAttribute('shape', 'block');
    swatch.setAttribute('color', '#3b82f6');
    host.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 30));

    // Remove from DOM — disconnected() should disconnect the observer.
    swatch.remove();
    await new Promise((r) => setTimeout(r, 30));

    // Subsequent appendChild on the now-orphaned host should not throw,
    // and re-attaching the swatch should re-arm a fresh observer.
    document.body.appendChild(swatch);
    await new Promise((r) => setTimeout(r, 30));

    const badge = document.createElement('span');
    badge.setAttribute('slot', 'chrome');
    badge.id = 'fb44-reattach-chrome';
    swatch.appendChild(badge);
    await new Promise((r) => setTimeout(r, 30));

    const found = swatch.querySelector('#fb44-reattach-chrome');
    expect(found).not.toBeNull();
    assertChromePositionedBeforeBadge(swatch, found);
    swatch.remove();
  });
});
