/**
 * input-ui — focused unit tests for the §199 (v0.5.7) leading/trailing
 * affordance-slot wiring.
 *
 * Pre-§199 the yaml declared `slot="leading"` + `slot="trailing"` since
 * v1 but nothing rendered them — consumer-authored buttons sat OUTSIDE
 * the field chrome. §199 closes the schema-vs-impl gap by moving
 * consumer-supplied affordance nodes into [slot="field"] at the right
 * insertion points on connected().
 */

import { describe, it, expect, beforeEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import '../../core/element.js';
import './input.js';
import { html, stamp } from '../../core/template.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const INPUT_CSS = readFileSync(resolve(__dirname, 'input.css'), 'utf8');

const tick = () => new Promise((r) => queueMicrotask(r));

function mount(html) {
  const wrap = document.createElement('div');
  wrap.innerHTML = html;
  document.body.appendChild(wrap);
  return wrap.firstElementChild;
}

describe('input-ui — §199 leading/trailing affordance slots', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('renders a baseline input without any affordances (no regression)', () => {
    const el = mount('<input-ui value="hello"></input-ui>');
    const field = el.querySelector(':scope > [slot="field"]');
    expect(field).not.toBeNull();
    expect(field.querySelector('[slot="text"]')).not.toBeNull();
    expect(field.querySelector('[slot="leading"]')).toBeNull();
    expect(field.querySelector('[slot="trailing"]')).toBeNull();
  });

  it('moves consumer [slot="trailing"] into [slot="field"]', () => {
    const el = mount(`
      <input-ui value="Theme 1" suffix="Light">
        <button slot="trailing" data-test="open" aria-label="Open">↗</button>
      </input-ui>
    `);
    const field = el.querySelector(':scope > [slot="field"]');
    const trailing = field.querySelector(':scope > [slot="trailing"]');
    expect(trailing).not.toBeNull();
    expect(trailing.dataset.test).toBe('open');
    // No stray trailing nodes left as direct children of <input-ui>
    expect(el.querySelector(':scope > [slot="trailing"]')).toBeNull();
  });

  it('moves consumer [slot="leading"] into [slot="field"]', () => {
    const el = mount(`
      <input-ui value="https://...">
        <button slot="leading" data-test="link" aria-label="Open link">↗</button>
      </input-ui>
    `);
    const field = el.querySelector(':scope > [slot="field"]');
    const leading = field.querySelector(':scope > [slot="leading"]');
    expect(leading).not.toBeNull();
    expect(leading.dataset.test).toBe('link');
    expect(el.querySelector(':scope > [slot="leading"]')).toBeNull();
  });

  // Wrapper-trap regression (audit-wrapper-trap arm 2): a `.map()`-interpolated
  // affordance arrives nested in a display:contents/role=presentation wrapper
  // span. The old `:scope > [slot]` direct-child grab missed it, so innerHTML=''
  // wiped it. logicalSlotted pierces the wrapper → it reaches the field.
  it('moves a WRAPPED (interpolated) [slot="trailing"] into the field (wrapper trap)', () => {
    const el = mount(`
      <input-ui value="Theme 1" suffix="Light">
        <span style="display:contents" role="presentation"><button slot="trailing" data-test="open" aria-label="Open">↗</button></span>
      </input-ui>
    `);
    const field = el.querySelector(':scope > [slot="field"]');
    const trailing = field.querySelector(':scope > [slot="trailing"]');
    expect(trailing).not.toBeNull();
    expect(trailing.dataset.test).toBe('open');
  });

  it('positions trailing after suffix and before controls (number mode)', () => {
    const el = mount(`
      <input-ui type="number" value="42" suffix="kg">
        <button slot="trailing" data-test="reset">↺</button>
      </input-ui>
    `);
    const field = el.querySelector(':scope > [slot="field"]');
    const children = Array.from(field.children);
    const suffixIdx = children.findIndex((c) => c.getAttribute('slot') === 'suffix');
    const trailingIdx = children.findIndex((c) => c.getAttribute('slot') === 'trailing');
    const controlsIdx = children.findIndex((c) => c.getAttribute('slot') === 'controls');
    expect(suffixIdx).toBeGreaterThanOrEqual(0);
    expect(trailingIdx).toBeGreaterThanOrEqual(0);
    expect(controlsIdx).toBeGreaterThanOrEqual(0);
    expect(suffixIdx).toBeLessThan(trailingIdx);
    expect(trailingIdx).toBeLessThan(controlsIdx);
  });

  it('positions leading after label (when label present)', () => {
    const el = mount(`
      <input-ui label="Theme" value="Theme 1">
        <button slot="leading" data-test="picker">🎨</button>
      </input-ui>
    `);
    const field = el.querySelector(':scope > [slot="field"]');
    const children = Array.from(field.children);
    const labelIdx = children.findIndex((c) => c.getAttribute('slot') === 'label');
    const leadingIdx = children.findIndex((c) => c.getAttribute('slot') === 'leading');
    const textIdx = children.findIndex((c) => c.getAttribute('slot') === 'text');
    expect(labelIdx).toBeGreaterThanOrEqual(0);
    expect(leadingIdx).toBeGreaterThanOrEqual(0);
    expect(textIdx).toBeGreaterThanOrEqual(0);
    expect(labelIdx).toBeLessThan(leadingIdx);
    expect(leadingIdx).toBeLessThan(textIdx);
  });

  it('supports multiple trailing nodes in author order', () => {
    const el = mount(`
      <input-ui value="hello">
        <button slot="trailing" data-test="a">A</button>
        <button slot="trailing" data-test="b">B</button>
      </input-ui>
    `);
    const field = el.querySelector(':scope > [slot="field"]');
    const trailings = Array.from(field.querySelectorAll(':scope > [slot="trailing"]'));
    expect(trailings.map((n) => n.dataset.test)).toEqual(['a', 'b']);
  });

  it('preserves contenteditable surface alongside affordances', () => {
    const el = mount(`
      <input-ui value="Theme 1" suffix="Light">
        <button slot="trailing" data-test="open">↗</button>
      </input-ui>
    `);
    const text = el.querySelector('[slot="text"]');
    expect(text).not.toBeNull();
    expect(text.getAttribute('contenteditable')).toBe('plaintext-only');
    expect(text.textContent).toBe('Theme 1');
  });
});

describe('input-ui — §220 (v0.5.9) throttle parity', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('inherits `throttle` property default 0 from UIFormElement', () => {
    const el = mount('<input-ui></input-ui>');
    expect(el.throttle).toBe(0);
  });

  it('reflects [throttle] attribute to the property', () => {
    const el = mount('<input-ui throttle="150"></input-ui>');
    expect(el.throttle).toBe(150);
  });

  it('dispatches `input` synchronously when throttle=0 (default)', async () => {
    const el = mount('<input-ui></input-ui>');
    await tick();
    let count = 0;
    // Filter for host-emitted CustomEvents (have detail.value); surface
    // InputEvents bubble up too but have no detail property.
    el.addEventListener('input', (e) => {
      if (e.detail?.value !== undefined) count++;
    });

    // Simulate user typing — set value + dispatch DOM input on the
    // contenteditable surface so the host's #onInput handler fires.
    const surface = el.querySelector('[slot="text"]');
    surface.textContent = 'a';
    surface.dispatchEvent(new InputEvent('input', { bubbles: true }));
    surface.textContent = 'ab';
    surface.dispatchEvent(new InputEvent('input', { bubbles: true }));
    surface.textContent = 'abc';
    surface.dispatchEvent(new InputEvent('input', { bubbles: true }));

    expect(count).toBe(3);
  });

  it('collapses burst dispatches to one trailing `input` when throttle>0', async () => {
    const el = mount('<input-ui throttle="20"></input-ui>');
    await tick();
    const events = [];
    // Filter for host-emitted CustomEvents (have detail.value); surface
    // InputEvents bubble up too but have no detail property.
    el.addEventListener('input', (e) => {
      if (e.detail?.value !== undefined) events.push(e.detail.value);
    });

    const surface = el.querySelector('[slot="text"]');
    surface.textContent = 'a';
    surface.dispatchEvent(new InputEvent('input', { bubbles: true }));
    surface.textContent = 'ab';
    surface.dispatchEvent(new InputEvent('input', { bubbles: true }));
    surface.textContent = 'abc';
    surface.dispatchEvent(new InputEvent('input', { bubbles: true }));

    expect(events).toEqual([]);

    await new Promise((r) => setTimeout(r, 35));
    expect(events).toEqual(['abc']);
  });

  it('flushPendingInput() fires the pending dispatch synchronously', async () => {
    const el = mount('<input-ui throttle="50"></input-ui>');
    await tick();
    const events = [];
    // Filter for host-emitted CustomEvents (have detail.value); surface
    // InputEvents bubble up too but have no detail property.
    el.addEventListener('input', (e) => {
      if (e.detail?.value !== undefined) events.push(e.detail.value);
    });

    const surface = el.querySelector('[slot="text"]');
    surface.textContent = 'pending';
    surface.dispatchEvent(new InputEvent('input', { bubbles: true }));

    expect(events).toEqual([]);
    el.flushPendingInput();
    expect(events).toEqual(['pending']);
  });

  it('dropPendingInput() prevents the dispatch (used by disconnected)', async () => {
    const el = mount('<input-ui throttle="20"></input-ui>');
    await tick();
    const events = [];
    // Filter for host-emitted CustomEvents (have detail.value); surface
    // InputEvents bubble up too but have no detail property.
    el.addEventListener('input', (e) => {
      if (e.detail?.value !== undefined) events.push(e.detail.value);
    });

    const surface = el.querySelector('[slot="text"]');
    surface.textContent = 'gone';
    surface.dispatchEvent(new InputEvent('input', { bubbles: true }));

    el.dropPendingInput();
    await new Promise((r) => setTimeout(r, 30));
    expect(events).toEqual([]);
  });
});

// ── Placeholder caret-position regression guard ─────────────────────
//
// Per the v0.6.35 bug report: with the `::before { content: attr(data-placeholder) }`
// pseudo rendered in inline flow, typing-then-deleting left the caret
// visually at the END of the placeholder text (not at position 0) because
// the in-flow pseudo box pushed the caret rendering position to its right.
// Fix: pseudo is `position: absolute` so it doesn't occupy inline-flow
// space; the host carries `position: relative` as its anchor.
// These tests assert the CSS-source contract that prevents accidental
// revert to the in-flow pseudo shape.

describe('input-ui — CSS source contract: placeholder pseudo is out of flow', () => {
  it('host [slot="text"] declares position: relative (pseudo anchor)', () => {
    expect(INPUT_CSS).toMatch(
      /\[slot="text"\]\s*\{[^}]*position:\s*relative/s
    );
  });

  it('[data-empty]::before is position: absolute (not inline-flow)', () => {
    expect(INPUT_CSS).toMatch(
      /span\[slot="text"\]\[data-empty\]::before\s*\{[^}]*position:\s*absolute/s
    );
  });

  it('[data-empty]::before fills the host content-box (inset: 0 + padding: inherit)', () => {
    expect(INPUT_CSS).toMatch(
      /span\[slot="text"\]\[data-empty\]::before\s*\{[^}]*inset:\s*0/s
    );
    expect(INPUT_CSS).toMatch(
      /span\[slot="text"\]\[data-empty\]::before\s*\{[^}]*padding:\s*inherit/s
    );
  });
});

describe('input-ui — FEEDBACK-93 reactive prefix/suffix re-sync', () => {
  const settle = () => new Promise((r) => setTimeout(r, 30));

  beforeEach(() => { document.body.innerHTML = ''; });

  function suffixOf(el) { return el.querySelector(':scope > [slot="field"] > [slot="suffix"]'); }
  function prefixOf(el) { return el.querySelector(':scope > [slot="field"] > [slot="prefix"]'); }

  it('resolves a reactive suffix binding instead of leaking the {{p:N}} marker (the bug)', async () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    // Interpolated attribute → the engine writes {{p:0}} into `suffix` before
    // connected() reads it, then resolves on the update() tick. Pre-fix the
    // baked marker stuck; post-fix render() re-resolves it.
    stamp(html`<input-ui type="number" suffix="${'px'}" value="2"></input-ui>`, container);
    await settle();

    const el = container.querySelector('input-ui');
    const suffix = suffixOf(el);
    expect(suffix).not.toBeNull();
    expect(suffix.textContent).toBe('px');
    expect(suffix.textContent).not.toMatch(/\{\{p:/);
  });

  it('resolves a reactive prefix binding', async () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    stamp(html`<input-ui prefix="${'$'}" value="9.99"></input-ui>`, container);
    await settle();

    const el = container.querySelector('input-ui');
    expect(prefixOf(el).textContent).toBe('$');
    expect(prefixOf(el).textContent).not.toMatch(/\{\{p:/);
  });

  it('re-resolves when the bound value later changes', async () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    stamp(html`<input-ui type="number" suffix="${'px'}" value="2"></input-ui>`, container);
    await settle();
    const el = container.querySelector('input-ui');
    expect(suffixOf(el).textContent).toBe('px');

    // Simulate the binding resolving to a new value on a later reactive tick.
    el.setAttribute('suffix', 'rem');
    await settle();
    expect(suffixOf(el).textContent).toBe('rem');
  });

  it('literal suffix still renders (no regression)', async () => {
    const el = mount('<input-ui suffix="%"></input-ui>');
    await settle();
    expect(suffixOf(el).textContent).toBe('%');
  });
});
