import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import { KeyboardInput } from '../game/input';
import { EMPTY_INPUT } from '../game/types';

class FakeElement extends EventTarget {
  isContentEditable = false;

  constructor(
    readonly tag = 'div',
    readonly role = '',
    readonly parent: FakeElement | null = null,
  ) { super(); }

  closest(selector: string): FakeElement | null {
    const matches = selector.split(',').map((part) => part.trim());
    if (matches.includes(this.tag) || matches.includes(`[role="${this.role}"]`)) return this;
    return this.parent?.closest(selector) ?? null;
  }
}

class FakeDocument extends EventTarget { hidden = false; }

type KeyOptions = { target?: FakeElement; repeat?: boolean; ctrlKey?: boolean; altKey?: boolean; metaKey?: boolean };

function setup(t: TestContext) {
  const fakeWindow = new EventTarget();
  const fakeDocument = new FakeDocument();
  const replacementGlobals = { window: fakeWindow, document: fakeDocument, Element: FakeElement, HTMLElement: FakeElement };
  const previous = new Map<string, PropertyDescriptor | undefined>();
  for (const [key, value] of Object.entries(replacementGlobals)) {
    previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
    Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
  }
  const calls = { pause: 0, confirm: 0, blur: 0 };
  const input = new KeyboardInput(() => calls.pause++, () => calls.confirm++, () => calls.blur++);
  t.after(() => {
    input.dispose();
    for (const [key, descriptor] of previous) {
      if (descriptor) Object.defineProperty(globalThis, key, descriptor);
      else Reflect.deleteProperty(globalThis, key);
    }
  });

  function key(type: 'keydown' | 'keyup', code: string, options: KeyOptions = {}) {
    const event = new Event(type, { cancelable: true });
    Object.defineProperties(event, {
      code: { value: code },
      target: { value: options.target ?? null },
      repeat: { value: options.repeat ?? false },
      ctrlKey: { value: options.ctrlKey ?? false },
      altKey: { value: options.altKey ?? false },
      metaKey: { value: options.metaKey ?? false },
    });
    fakeWindow.dispatchEvent(event);
    return event;
  }
  return { input, calls, fakeWindow, fakeDocument, key };
}

test('alternate bindings remain held until the final physical key is released', (t) => {
  const { input, key } = setup(t);
  key('keydown', 'KeyJ');
  assert.equal(input.read().shootPressed, true);
  key('keydown', 'KeyX');
  assert.equal(input.read().shootPressed, false);
  key('keyup', 'KeyJ');
  let snapshot = input.read();
  assert.equal(snapshot.shoot, true);
  assert.equal(snapshot.shootReleased, false);
  key('keyup', 'KeyX');
  snapshot = input.read();
  assert.equal(snapshot.shoot, false);
  assert.equal(snapshot.shootReleased, true);

  key('keydown', 'Space');
  key('keydown', 'KeyZ');
  input.read();
  key('keyup', 'Space');
  snapshot = input.read();
  assert.equal(snapshot.jump, true);
  assert.equal(snapshot.jumpReleased, false);
  key('keyup', 'KeyZ');
  assert.equal(input.read().jumpReleased, true);

  key('keydown', 'ArrowLeft');
  key('keydown', 'KeyA');
  key('keyup', 'ArrowLeft');
  assert.equal(input.read().left, true);
  key('keyup', 'KeyA');
  assert.equal(input.read().left, false);
  key('keydown', 'ArrowRight');
  key('keydown', 'KeyD');
  key('keyup', 'KeyD');
  assert.equal(input.read().right, true);
});

test('reads consume edges while preserving holds and ignoring key repeat', (t) => {
  const { input, key } = setup(t);
  assert.equal(key('keydown', 'Space').defaultPrevented, true);
  assert.equal(input.read().jumpPressed, true);
  let snapshot = input.read();
  assert.equal(snapshot.jumpPressed, false);
  assert.equal(snapshot.jump, true);
  key('keydown', 'Space', { repeat: true });
  assert.equal(input.read().jumpPressed, false);
  key('keyup', 'Space');
  assert.equal(input.read().jumpReleased, true);
  assert.equal(input.read().jumpReleased, false);

  key('keydown', 'KeyX');
  key('keyup', 'KeyX');
  snapshot = input.read();
  assert.equal(snapshot.shootPressed, true);
  assert.equal(snapshot.shootReleased, true);
  assert.equal(snapshot.shoot, false);

  key('keydown', 'ShiftLeft');
  assert.equal(input.read().dashPressed, true);
  key('keydown', 'ShiftRight');
  key('keydown', 'KeyC');
  assert.equal(input.read().dashPressed, false);
  key('keyup', 'ShiftLeft');
  key('keyup', 'ShiftRight');
  key('keyup', 'KeyC');
  key('keydown', 'KeyC');
  assert.equal(input.read().dashPressed, true);
});

test('form controls, contenteditable content, and browser shortcuts retain their keys', (t) => {
  const { input, calls, key } = setup(t);
  const editable = new FakeElement();
  editable.isContentEditable = true;
  const formChild = new FakeElement('span', '', new FakeElement('form'));
  const fields = ['input', 'textarea', 'select', 'form'].map((tag) => new FakeElement(tag));
  fields.push(editable, formChild, new FakeElement('div', 'textbox'));
  for (const target of fields) {
    for (const code of ['Space', 'KeyJ', 'ArrowLeft', 'Enter', 'Escape']) {
      assert.equal(key('keydown', code, { target }).defaultPrevented, false);
      assert.equal(key('keyup', code, { target }).defaultPrevented, false);
    }
  }
  for (const modifier of ['ctrlKey', 'altKey', 'metaKey'] as const) {
    assert.equal(key('keydown', 'KeyP', { [modifier]: true }).defaultPrevented, false);
    assert.equal(key('keydown', 'KeyX', { [modifier]: true }).defaultPrevented, false);
  }
  assert.equal(key('keydown', 'KeyQ').defaultPrevented, false);
  assert.deepEqual(input.read(), EMPTY_INPUT);
  assert.deepEqual(calls, { pause: 0, confirm: 0, blur: 0 });
});

test('buttons keep Space and Enter activation without duplicate game actions', (t) => {
  const { input, calls, key } = setup(t);
  for (const target of [new FakeElement('button'), new FakeElement('div', 'button'), new FakeElement('span', '', new FakeElement('button'))]) {
    for (const code of ['Space', 'Enter', 'NumpadEnter']) {
      assert.equal(key('keydown', code, { target }).defaultPrevented, false);
      assert.equal(key('keyup', code, { target }).defaultPrevented, false);
    }
  }
  assert.deepEqual(input.read(), EMPTY_INPUT);
  assert.equal(calls.confirm, 0);

  key('keydown', 'Space');
  input.read();
  key('keyup', 'Space', { target: new FakeElement('button') });
  assert.equal(input.read().jumpReleased, true, 'focus changes must still release existing game input');
});

test('pause and confirmation fire once per nonrepeating key press', (t) => {
  const { calls, key } = setup(t);
  for (const code of ['Escape', 'KeyP']) {
    key('keydown', code);
    key('keydown', code, { repeat: true });
    key('keyup', code);
  }
  for (const code of ['Enter', 'NumpadEnter']) {
    key('keydown', code);
    key('keydown', code, { repeat: true });
    key('keyup', code);
  }
  assert.deepEqual(calls, { pause: 2, confirm: 2, blur: 0 });
});

test('clear, blur, and hidden documents discard held keys and pending edges', (t) => {
  const { input, calls, fakeWindow, fakeDocument, key } = setup(t);
  key('keydown', 'ArrowRight');
  key('keydown', 'KeyZ');
  input.clear();
  assert.deepEqual(input.read(), EMPTY_INPUT);
  key('keydown', 'KeyX');
  key('keydown', 'ShiftLeft');
  fakeWindow.dispatchEvent(new Event('blur'));
  assert.deepEqual(input.read(), EMPTY_INPUT);
  assert.equal(calls.blur, 1);
  key('keydown', 'KeyD');
  fakeDocument.hidden = true;
  fakeDocument.dispatchEvent(new Event('visibilitychange'));
  assert.deepEqual(input.read(), EMPTY_INPUT);
  assert.equal(calls.blur, 2);
  fakeDocument.hidden = false;
  fakeDocument.dispatchEvent(new Event('visibilitychange'));
  assert.equal(calls.blur, 2);
});

test('dispose removes keyboard, blur, and visibility listeners and is idempotent', (t) => {
  const { input, calls, fakeWindow, fakeDocument, key } = setup(t);
  key('keydown', 'ArrowLeft');
  input.dispose();
  input.dispose();
  assert.deepEqual(input.read(), EMPTY_INPUT);
  key('keydown', 'KeyZ');
  key('keydown', 'Enter');
  key('keydown', 'Escape');
  key('keyup', 'KeyZ');
  fakeWindow.dispatchEvent(new Event('blur'));
  fakeDocument.hidden = true;
  fakeDocument.dispatchEvent(new Event('visibilitychange'));
  assert.deepEqual(input.read(), EMPTY_INPUT);
  assert.deepEqual(calls, { pause: 0, confirm: 0, blur: 0 });
});
