import { describe, it, expect, beforeEach } from 'vitest';
import '../../core/element.js';
import '../tag/tag.js';
import './tags-input.js';

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;
}

function typeText(el, text) {
  // Drive the contenteditable surface like a real keyboard. Append the
  // character into textContent + fire `input` so the host runs through its
  // own input handler.
  const surface = el.querySelector(':scope > [data-inline-input]');
  surface.textContent = (surface.textContent || '') + text;
  surface.dispatchEvent(new InputEvent('input', { bubbles: true }));
}

function setText(el, text) {
  // Like typeText but REPLACES the buffer. Use to simulate a user clearing
  // a failed attempt and starting over.
  const surface = el.querySelector(':scope > [data-inline-input]');
  surface.textContent = text;
  surface.dispatchEvent(new InputEvent('input', { bubbles: true }));
}

function pressKey(el, key) {
  const surface = el.querySelector(':scope > [data-inline-input]');
  surface.focus();
  surface.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}

describe('tags-input-ui — structure + defaults', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('registers the custom element', () => {
    expect(customElements.get('tags-input-ui')).toBeTruthy();
  });

  it('stamps a chip list, inline input, spinner, and suggestion popover', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    expect(el.querySelector(':scope > [data-chip-list]')).toBeTruthy();
    expect(el.querySelector(':scope > [data-inline-input]')).toBeTruthy();
    expect(el.querySelector(':scope > [data-spinner]')).toBeTruthy();
    expect(el.querySelector(':scope > [data-suggestions]')).toBeTruthy();
  });

  it('host carries role=group by default (no suggestions wired)', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    expect(el.getAttribute('role')).toBe('group');
  });

  it('inline input carries role=searchbox + plaintext contenteditable', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    const input = el.querySelector(':scope > [data-inline-input]');
    expect(input.getAttribute('role')).toBe('searchbox');
    expect(input.getAttribute('contenteditable')).toBe('plaintext-only');
  });
});

describe('tags-input-ui — commit behavior', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('Enter commits the typed value as a chip', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    typeText(el, 'hello');
    pressKey(el, 'Enter');
    await tick();
    expect(el.value).toEqual(['hello']);
    expect(el.querySelectorAll(':scope > [data-chip-list] > tag-ui').length).toBe(1);
  });

  it('default comma delimiter commits inline', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    typeText(el, 'foo,');
    await tick();
    expect(el.value).toEqual(['foo']);
  });

  it('delimiter="enter" disables in-line comma commit', async () => {
    const el = mount('<tags-input-ui delimiter="enter"></tags-input-ui>');
    await tick();
    typeText(el, 'foo,bar');
    await tick();
    // Nothing committed yet — the comma is treated as literal text.
    expect(el.value).toEqual([]);
    pressKey(el, 'Enter');
    await tick();
    expect(el.value).toEqual(['foo,bar']);
  });

  it('Backspace from empty input removes the last chip', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    el.value = ['alpha', 'beta'];
    await tick();
    expect(el.value).toEqual(['alpha', 'beta']);
    pressKey(el, 'Backspace');
    await tick();
    expect(el.value).toEqual(['alpha']);
  });

  it('Backspace with non-empty input only deletes a character (no chip removal)', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    el.value = ['alpha'];
    typeText(el, 'wip');
    await tick();
    pressKey(el, 'Backspace');
    await tick();
    // Chip unchanged.
    expect(el.value).toEqual(['alpha']);
  });

  it('unique:true (default) silently dedups duplicate commits', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    let invalidCount = 0;
    el.addEventListener('invalid', () => { invalidCount += 1; });
    typeText(el, 'foo');
    pressKey(el, 'Enter');
    await tick();
    typeText(el, 'foo');
    pressKey(el, 'Enter');
    await tick();
    expect(el.value).toEqual(['foo']);
    expect(invalidCount).toBe(0);
  });

  it('min-length rejects too-short tokens with reason=too-short', async () => {
    const el = mount('<tags-input-ui min-length="3"></tags-input-ui>');
    await tick();
    let detail = null;
    el.addEventListener('invalid', (e) => { detail = e.detail; });
    typeText(el, 'ab');
    pressKey(el, 'Enter');
    await tick();
    expect(el.value).toEqual([]);
    expect(detail?.reason).toBe('too-short');
  });

  it('max-length rejects too-long tokens with reason=too-long', async () => {
    const el = mount('<tags-input-ui max-length="5"></tags-input-ui>');
    await tick();
    let detail = null;
    el.addEventListener('invalid', (e) => { detail = e.detail; });
    typeText(el, 'too-long-value');
    pressKey(el, 'Enter');
    await tick();
    expect(el.value).toEqual([]);
    expect(detail?.reason).toBe('too-long');
  });

  it('max count cap rejects further commits with reason=max', async () => {
    const el = mount('<tags-input-ui max="2"></tags-input-ui>');
    await tick();
    let invalidDetail = null;
    el.addEventListener('invalid', (e) => { invalidDetail = e.detail; });
    el.addToken('one');
    el.addToken('two');
    expect(el.value).toEqual(['one', 'two']);
    typeText(el, 'three');
    pressKey(el, 'Enter');
    await tick();
    expect(el.value).toEqual(['one', 'two']);
    expect(invalidDetail?.reason).toBe('max');
  });

  it('transform="lowercase" normalizes tokens on commit', async () => {
    const el = mount('<tags-input-ui transform="lowercase"></tags-input-ui>');
    await tick();
    el.addToken('FOO');
    el.addToken('Bar');
    expect(el.value).toEqual(['foo', 'bar']);
  });

  it('transform="strip-spaces" removes all whitespace before commit', async () => {
    const el = mount('<tags-input-ui transform="strip-spaces"></tags-input-ui>');
    await tick();
    el.addToken('hello world');
    expect(el.value).toEqual(['helloworld']);
  });
});

describe('tags-input-ui — validators', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('sync validateFn rejects invalid tokens with reason=validator', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    el.validateFn = (v) => /^[a-z]+$/.test(v);
    let detail = null;
    el.addEventListener('invalid', (e) => { detail = e.detail; });
    typeText(el, '123');
    pressKey(el, 'Enter');
    await tick();
    expect(el.value).toEqual([]);
    expect(detail?.reason).toBe('validator');
    // Accepts valid input.
    setText(el, 'ok');
    pressKey(el, 'Enter');
    await tick();
    expect(el.value).toEqual(['ok']);
  });

  it('async validateFn flips host into [validating] then commits on resolve(true)', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    let resolveCheck;
    el.validateFn = () => new Promise((r) => { resolveCheck = r; });
    typeText(el, 'pending');
    pressKey(el, 'Enter');
    await tick();
    expect(el.hasAttribute('validating')).toBe(true);
    expect(el.value).toEqual([]);
    resolveCheck(true);
    await tick();
    await tick();
    expect(el.hasAttribute('validating')).toBe(false);
    expect(el.value).toEqual(['pending']);
  });

  it('commit event with preventDefault() rejects the token (reason=validator)', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    el.addEventListener('commit', (e) => e.preventDefault());
    let detail = null;
    el.addEventListener('invalid', (e) => { detail = e.detail; });
    typeText(el, 'blocked');
    pressKey(el, 'Enter');
    await tick();
    expect(el.value).toEqual([]);
    expect(detail?.reason).toBe('validator');
  });
});

describe('tags-input-ui — paste-split', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('pasting "a, b, c" with default paste-split commits three tokens', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    const surface = el.querySelector(':scope > [data-inline-input]');
    const dt = new DataTransfer();
    dt.setData('text/plain', 'a, b, c');
    surface.dispatchEvent(new ClipboardEvent('paste', {
      bubbles: true,
      cancelable: true,
      clipboardData: dt,
    }));
    await tick();
    expect(el.value).toEqual(['a', 'b', 'c']);
  });
});

describe('tags-input-ui — disabled / readonly / form', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('disabled blocks all interaction', async () => {
    const el = mount('<tags-input-ui disabled></tags-input-ui>');
    await tick();
    typeText(el, 'nope');
    pressKey(el, 'Enter');
    await tick();
    expect(el.value).toEqual([]);
  });

  it('readonly blocks edits but allows inspection (chips lack [removable])', async () => {
    const el = mount('<tags-input-ui readonly value=\'["frozen"]\'></tags-input-ui>');
    await tick();
    expect(el.value).toEqual(['frozen']);
    const chip = el.querySelector(':scope > [data-chip-list] > tag-ui');
    expect(chip).toBeTruthy();
    expect(chip.hasAttribute('removable')).toBe(false);
  });

  it('exposes UITagsInput extending UIFormElement (form participation)', async () => {
    const el = mount('<tags-input-ui name="x"></tags-input-ui>');
    await tick();
    expect(typeof el.checkValidity).toBe('function');
    expect(typeof el.syncValue).toBe('function');
  });

  it('form value serializes as JSON array under [name]', async () => {
    const el = mount('<tags-input-ui name="labels"></tags-input-ui>');
    await tick();
    el.addToken('bug');
    el.addToken('high-priority');
    // happy-dom's ElementInternals tracks form value via setFormValue — read
    // it back via the public `value` accessor + assert via internals' state.
    expect(el.value).toEqual(['bug', 'high-priority']);
    expect(el.checkValidity()).toBe(true);
  });

  it('required attribute reflects on the host (happy-dom internals stub limits deeper assertions)', async () => {
    const el = mount('<tags-input-ui required name="x"></tags-input-ui>');
    await tick();
    expect(el.required).toBe(true);
    expect(el.hasAttribute('required')).toBe(true);
    // ElementInternals.setValidity is a no-op in happy-dom, so we can't
    // assert validity.valueMissing here — gate that in browser smokes.
    el.addToken('one');
    expect(el.value).toEqual(['one']);
  });

  it('parses declarative value="[…]" as a JSON array', async () => {
    const el = mount('<tags-input-ui value=\'["a","b","c"]\'></tags-input-ui>');
    await tick();
    expect(el.value).toEqual(['a', 'b', 'c']);
  });
});

describe('tags-input-ui — change event', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('change event detail carries value + added + removed', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    const events = [];
    el.addEventListener('change', (e) => events.push(e.detail));
    el.addToken('first');
    el.addToken('second');
    await tick();
    expect(events.length).toBe(2);
    expect(events[0]).toMatchObject({ added: ['first'], removed: [] });
    expect(events[1]).toMatchObject({ added: ['second'], removed: [] });
    expect(events[1].value).toEqual(['first', 'second']);
    el.removeToken(0);
    await tick();
    expect(events[2]).toMatchObject({ added: [], removed: ['first'] });
  });
});

describe('tags-input-ui — suggestions', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('host carries role=combobox when .suggestions is non-empty', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    el.suggestions = ['alpha', 'beta', 'gamma'];
    await tick();
    expect(el.getAttribute('role')).toBe('combobox');
    expect(el.getAttribute('aria-haspopup')).toBe('listbox');
  });

  it('clicking a suggestion option commits it', async () => {
    const el = mount('<tags-input-ui></tags-input-ui>');
    await tick();
    el.suggestions = ['alpha', 'beta'];
    typeText(el, 'a');
    await tick();
    const opt = el.querySelector(':scope > [data-suggestions] > [role="option"][data-value="alpha"]');
    expect(opt).toBeTruthy();
    opt.click();
    await tick();
    expect(el.value).toEqual(['alpha']);
  });
});
