const ACTION_KEYS = Object.freeze({
  jump: { code: 'Space', key: ' ' },
  crouch: { code: 'ControlLeft', key: 'Control' },
  reload: { code: 'KeyR', key: 'r' },
  use: { code: 'KeyE', key: 'e' },
  score: { code: 'Tab', key: 'Tab' },
  menu: { code: 'Escape', key: 'Escape' },
  ptt: { code: 'KeyT', key: 't' }
});

const KEYBOARD_ACTIONS = new Map([
  ['Space', 'jump'],
  ['ControlLeft', 'crouch'],
  ['ControlRight', 'crouch'],
  ['KeyC', 'crouch'],
  ['KeyR', 'reload'],
  ['KeyE', 'use'],
  ['KeyF', 'use'],
  ['Tab', 'score'],
  ['Escape', 'menu'],
  ['KeyT', 'ptt']
]);

const SYNTHETIC_MOUSE_POINTER_ID = 1;

function interactiveKeyboardTarget(target) {
  return Boolean(target?.closest?.(
    'button, a[href], input, select, textarea, [contenteditable]:not([contenteditable="false"]), [role="button"], [role="link"]'
  ));
}

function clamp(value, minimum, maximum) {
  return Math.max(minimum, Math.min(maximum, value));
}

function deadzone(value, threshold = 0.16) {
  if (Math.abs(value) <= threshold) return 0;
  return Math.sign(value) * ((Math.abs(value) - threshold) / (1 - threshold));
}

export class BrowserInputSink {
  constructor({ target, adapter = () => globalThis.Q4EngineAdapter, onMenu = null } = {}) {
    this.target = target;
    this.adapter = adapter;
    this.onMenu = onMenu;
    this.moveKeys = new Map();
    this.pointerButtons = 0;
    this.pointerPosition = null;
  }

  engineAdapter() {
    try {
      return typeof this.adapter === 'function' ? this.adapter() : this.adapter;
    } catch (_error) {
      return null;
    }
  }

  dispatchKeyboard(code, key, down) {
    if (!this.target || typeof KeyboardEvent !== 'function') return;
    this.target.dispatchEvent(new KeyboardEvent(down ? 'keydown' : 'keyup', {
      code,
      key,
      bubbles: true,
      cancelable: true
    }));
  }

  dispatchPointer(type, { button = -1, buttons = this.pointerButtons, movementX = 0, movementY = 0 } = {}) {
    const PointerEventConstructor = this.target?.ownerDocument?.defaultView?.PointerEvent || globalThis.PointerEvent;
    if (!this.target || typeof PointerEventConstructor !== 'function') return;

    const rect = this.target.getBoundingClientRect?.() || {};
    const left = Number(rect.left) || 0;
    const top = Number(rect.top) || 0;
    const width = Math.max(0, Number(rect.width) || 0);
    const height = Math.max(0, Number(rect.height) || 0);
    if (!this.pointerPosition) {
      this.pointerPosition = { x: left + width / 2, y: top + height / 2 };
    }
    this.pointerPosition.x = clamp(this.pointerPosition.x + movementX, left, left + Math.max(0, width - 1));
    this.pointerPosition.y = clamp(this.pointerPosition.y + movementY, top, top + Math.max(0, height - 1));

    const event = new PointerEventConstructor(type, {
      pointerId: SYNTHETIC_MOUSE_POINTER_ID,
      pointerType: 'mouse',
      isPrimary: true,
      button,
      buttons,
      clientX: this.pointerPosition.x,
      clientY: this.pointerPosition.y,
      movementX,
      movementY,
      bubbles: true,
      cancelable: true
    });
    if (event.movementX !== movementX || event.movementY !== movementY) {
      try {
        Object.defineProperties(event, {
          movementX: { value: movementX },
          movementY: { value: movementY }
        });
      } catch (_error) {
        // Older PointerEvent implementations may expose fixed movement fields.
      }
    }
    this.target.dispatchEvent(event);
  }

  action(name, down, source = '') {
    if (name === 'menu' && typeof this.onMenu === 'function') {
      if (down) this.onMenu({ source });
      return;
    }
    if (name === 'ptt') {
      if (typeof CustomEvent === 'function') {
        globalThis.dispatchEvent?.(new CustomEvent('q4-ptt', { detail: { active: down } }));
      }
      return;
    }
    const adapter = this.engineAdapter();
    if (typeof adapter?.inputAction === 'function') {
      adapter.inputAction(name, down);
      return;
    }
    if (String(source).startsWith('keyboard:')) return;

    if (name === 'fire') {
      this.pointerButtons = down ? 1 : 0;
      this.dispatchPointer(down ? 'pointerdown' : 'pointerup', {
        button: 0,
        buttons: this.pointerButtons
      });
      return;
    }

    const mapping = ACTION_KEYS[name];
    if (mapping) this.dispatchKeyboard(mapping.code, mapping.key, down);
  }

  move(x, y) {
    const adapter = this.engineAdapter();
    if (typeof adapter?.inputMove === 'function') {
      adapter.inputMove(x, y);
      return;
    }

    const desired = new Map([
      ['KeyA', x < -0.24],
      ['KeyD', x > 0.24],
      ['KeyW', y < -0.24],
      ['KeyS', y > 0.24]
    ]);
    for (const [code, down] of desired) {
      if (this.moveKeys.get(code) === down) continue;
      this.moveKeys.set(code, down);
      this.dispatchKeyboard(code, code.at(-1).toLowerCase(), down);
    }
  }

  look(deltaX, deltaY) {
    const adapter = this.engineAdapter();
    if (typeof adapter?.inputLook === 'function') {
      adapter.inputLook(deltaX, deltaY);
      return;
    }
    this.dispatchPointer('pointermove', {
      buttons: this.pointerButtons,
      movementX: deltaX,
      movementY: deltaY
    });
  }

  weapon(step) {
    const adapter = this.engineAdapter();
    if (typeof adapter?.inputWeapon === 'function') {
      adapter.inputWeapon(step);
      return;
    }
    if (!this.target || typeof WheelEvent !== 'function') return;
    this.target.dispatchEvent(new WheelEvent('wheel', {
      deltaY: step > 0 ? 100 : -100,
      bubbles: true,
      cancelable: true
    }));
  }
}

export class InputRouter {
  constructor(sink = {}) {
    this.sink = sink;
    this.actionSources = new Map();
    this.moveSources = new Map();
  }

  press(action, source = 'default') {
    let sources = this.actionSources.get(action);
    if (!sources) {
      sources = new Set();
      this.actionSources.set(action, sources);
    }
    const wasActive = sources.size > 0;
    sources.add(source);
    if (!wasActive) this.sink.action?.(action, true, source);
  }

  release(action, source = 'default') {
    const sources = this.actionSources.get(action);
    if (!sources || !sources.delete(source)) return;
    if (sources.size === 0) {
      this.actionSources.delete(action);
      this.sink.action?.(action, false, source);
    }
  }

  pulse(action, source = 'default') {
    if (action === 'weapon-prev' || action === 'weapon-next') {
      this.sink.weapon?.(action === 'weapon-next' ? 1 : -1, source);
      return;
    }
    this.press(action, source);
    this.release(action, source);
  }

  setMove(source, x, y) {
    const moveX = clamp(Number(x) || 0, -1, 1);
    const moveY = clamp(Number(y) || 0, -1, 1);
    if (moveX === 0 && moveY === 0) this.moveSources.delete(source);
    else this.moveSources.set(source, { x: moveX, y: moveY });

    let combinedX = 0;
    let combinedY = 0;
    for (const move of this.moveSources.values()) {
      combinedX = clamp(combinedX + move.x, -1, 1);
      combinedY = clamp(combinedY + move.y, -1, 1);
    }
    this.sink.move?.(combinedX, combinedY, source);
  }

  look(deltaX, deltaY, source = 'default') {
    const x = Number(deltaX) || 0;
    const y = Number(deltaY) || 0;
    if (x || y) this.sink.look?.(x, y, source);
  }

  releaseSource(source) {
    for (const action of [...this.actionSources.keys()]) this.release(action, source);
    if (this.moveSources.has(source)) this.setMove(source, 0, 0);
  }

  releaseAll() {
    for (const [action, sources] of [...this.actionSources]) {
      for (const source of [...sources]) this.release(action, source);
    }
    this.moveSources.clear();
    this.sink.move?.(0, 0, 'release-all');
  }
}

export class TouchController {
  constructor({ overlay, moveZone, moveKnob, lookZone, router, windowObject = globalThis, documentObject = globalThis.document }) {
    this.overlay = overlay;
    this.moveZone = moveZone;
    this.moveKnob = moveKnob;
    this.lookZone = lookZone;
    this.router = router;
    this.windowObject = windowObject;
    this.documentObject = documentObject;
    this.movePointer = null;
    this.moveCenter = null;
    this.lookPointer = null;
    this.lookLast = null;
    this.buttonPointers = new Map();
    this.abortController = new AbortController();
  }

  install() {
    const options = { signal: this.abortController.signal };
    this.moveZone.addEventListener('pointerdown', (event) => this.startMove(event), options);
    this.moveZone.addEventListener('pointermove', (event) => this.updateMove(event), options);
    this.moveZone.addEventListener('pointerup', (event) => this.endMove(event), options);
    this.moveZone.addEventListener('pointercancel', (event) => this.endMove(event), options);
    this.moveZone.addEventListener('lostpointercapture', (event) => this.endMove(event), options);

    this.lookZone.addEventListener('pointerdown', (event) => this.startLook(event), options);
    this.lookZone.addEventListener('pointermove', (event) => this.updateLook(event), options);
    this.lookZone.addEventListener('pointerup', (event) => this.endLook(event), options);
    this.lookZone.addEventListener('pointercancel', (event) => this.endLook(event), options);
    this.lookZone.addEventListener('lostpointercapture', (event) => this.endLook(event), options);

    for (const button of this.overlay.querySelectorAll('[data-action]')) {
      button.addEventListener('pointerdown', (event) => this.pressButton(event, button), options);
      button.addEventListener('pointerup', (event) => this.releaseButton(event, button), options);
      button.addEventListener('pointercancel', (event) => this.releaseButton(event, button), options);
      button.addEventListener('lostpointercapture', (event) => this.releaseButton(event, button), options);
    }

    this.windowObject.addEventListener?.('blur', () => this.releaseAll(), options);
    this.documentObject?.addEventListener('visibilitychange', () => {
      if (this.documentObject.hidden) this.releaseAll();
    }, options);
    return this;
  }

  startMove(event) {
    if (this.movePointer !== null) return;
    event.preventDefault();
    this.movePointer = event.pointerId;
    this.moveZone.setPointerCapture?.(event.pointerId);
    const base = this.moveKnob.parentElement.getBoundingClientRect();
    this.moveCenter = { x: base.left + base.width / 2, y: base.top + base.height / 2, radius: base.width * 0.42 };
    this.updateMove(event);
  }

  updateMove(event) {
    if (event.pointerId !== this.movePointer || !this.moveCenter) return;
    event.preventDefault();
    let x = (event.clientX - this.moveCenter.x) / this.moveCenter.radius;
    let y = (event.clientY - this.moveCenter.y) / this.moveCenter.radius;
    const length = Math.hypot(x, y);
    if (length > 1) {
      x /= length;
      y /= length;
    }
    this.moveKnob.style.transform = `translate3d(${x * this.moveCenter.radius}px, ${y * this.moveCenter.radius}px, 0)`;
    this.router.setMove(`touch:${event.pointerId}`, x, y);
  }

  endMove(event) {
    if (event.pointerId !== this.movePointer) return;
    const source = `touch:${event.pointerId}`;
    this.router.setMove(source, 0, 0);
    this.movePointer = null;
    this.moveCenter = null;
    this.moveKnob.style.transform = 'translate3d(0, 0, 0)';
  }

  startLook(event) {
    if (this.lookPointer !== null) return;
    event.preventDefault();
    this.lookPointer = event.pointerId;
    this.lookLast = { x: event.clientX, y: event.clientY };
    this.lookZone.setPointerCapture?.(event.pointerId);
  }

  updateLook(event) {
    if (event.pointerId !== this.lookPointer || !this.lookLast) return;
    event.preventDefault();
    const deltaX = event.clientX - this.lookLast.x;
    const deltaY = event.clientY - this.lookLast.y;
    this.lookLast = { x: event.clientX, y: event.clientY };
    this.router.look(deltaX * 1.35, deltaY * 1.35, `touch:${event.pointerId}`);
  }

  endLook(event) {
    if (event.pointerId !== this.lookPointer) return;
    this.lookPointer = null;
    this.lookLast = null;
  }

  pressButton(event, button) {
    event.preventDefault();
    event.stopPropagation();
    const action = button.dataset.action;
    const source = `touch:${event.pointerId}`;
    button.setPointerCapture?.(event.pointerId);
    button.dataset.active = 'true';
    this.buttonPointers.set(event.pointerId, { action, button, source });
    if (action === 'weapon-prev' || action === 'weapon-next') this.router.pulse(action, source);
    else this.router.press(action, source);
  }

  releaseButton(event, fallbackButton) {
    const held = this.buttonPointers.get(event.pointerId);
    if (!held) {
      if (fallbackButton) fallbackButton.dataset.active = 'false';
      return;
    }
    event.preventDefault();
    this.buttonPointers.delete(event.pointerId);
    held.button.dataset.active = String([...this.buttonPointers.values()].some((entry) => entry.button === held.button));
    if (held.action !== 'weapon-prev' && held.action !== 'weapon-next') {
      this.router.release(held.action, held.source);
    }
  }

  releaseAll() {
    for (const held of this.buttonPointers.values()) {
      held.button.dataset.active = 'false';
      this.router.release(held.action, held.source);
    }
    this.buttonPointers.clear();
    if (this.movePointer !== null) this.router.setMove(`touch:${this.movePointer}`, 0, 0);
    this.movePointer = null;
    this.moveCenter = null;
    this.lookPointer = null;
    this.lookLast = null;
    this.moveKnob.style.transform = 'translate3d(0, 0, 0)';
  }

  destroy() {
    this.releaseAll();
    this.abortController.abort();
  }
}

export class GamepadController {
  constructor({ router, active = () => true, navigatorObject = globalThis.navigator, windowObject = globalThis }) {
    this.router = router;
    this.active = active;
    this.navigatorObject = navigatorObject;
    this.windowObject = windowObject;
    this.running = false;
    this.seen = new Set();
    this.buttonState = new Map();
    this.frame = null;
  }

  start() {
    if (this.running) return;
    this.running = true;
    const tick = () => {
      if (!this.running) return;
      this.poll();
      this.frame = this.windowObject.requestAnimationFrame?.(tick);
    };
    tick();
  }

  poll() {
    if (!this.active()) {
      this.releaseAll();
      return;
    }
    const gamepads = this.navigatorObject?.getGamepads?.() || [];
    const connected = new Set();
    for (const gamepad of gamepads) {
      if (!gamepad) continue;
      const source = `gamepad:${gamepad.index}`;
      connected.add(source);
      this.seen.add(source);
      this.router.setMove(source, deadzone(gamepad.axes[0]), deadzone(gamepad.axes[1]));
      this.router.look(deadzone(gamepad.axes[2]) * 18, deadzone(gamepad.axes[3]) * 18, source);

      const mappings = new Map([
        [0, 'jump'], [1, 'crouch'], [2, 'reload'], [3, 'use'],
        [4, 'weapon-prev'], [5, 'weapon-next'], [7, 'fire'],
        [8, 'score'], [9, 'menu'], [11, 'use']
      ]);
      for (const [buttonIndex, action] of mappings) {
        const stateKey = `${source}:${buttonIndex}`;
        const pressed = Boolean(gamepad.buttons[buttonIndex]?.pressed || gamepad.buttons[buttonIndex]?.value > 0.55);
        const previous = this.buttonState.get(stateKey) || false;
        if (pressed === previous) continue;
        this.buttonState.set(stateKey, pressed);
        if (action === 'weapon-prev' || action === 'weapon-next') {
          if (pressed) this.router.pulse(action, source);
        } else if (pressed) this.router.press(action, source);
        else this.router.release(action, source);
      }
    }

    for (const source of [...this.seen]) {
      if (connected.has(source)) continue;
      this.router.releaseSource(source);
      this.seen.delete(source);
      for (const key of [...this.buttonState.keys()]) {
        if (key.startsWith(`${source}:`)) this.buttonState.delete(key);
      }
    }
  }

  releaseAll() {
    for (const source of this.seen) this.router.releaseSource(source);
    this.seen.clear();
    this.buttonState.clear();
  }

  stop() {
    this.running = false;
    if (this.frame != null) this.windowObject.cancelAnimationFrame?.(this.frame);
    this.frame = null;
    this.releaseAll();
  }
}

export function installKeyboardInput({
  canvas,
  router,
  active = () => true,
  onMenu = null,
  windowObject = globalThis
}) {
  const controller = new AbortController();
  const options = { signal: controller.signal };
  const keyboardOptions = { signal: controller.signal, capture: true };
  const handle = (event, down) => {
    if (!active()) return;
    const action = KEYBOARD_ACTIONS.get(event.code);
    if (action === 'menu' && typeof onMenu === 'function') {
      if (down && !event.repeat) onMenu(event);
      event.preventDefault();
      event.stopImmediatePropagation();
      return;
    }
    if (interactiveKeyboardTarget(event.target)) {
      // This listener is registered before SDL. Keep UI controls keyboard-operable
      // without cancelling their browser-default activation or editing behavior.
      event.stopImmediatePropagation();
      return;
    }
    if (action) {
      const source = `keyboard:${event.code}`;
      if (down && !event.repeat) router.press(action, source);
      if (!down) router.release(action, source);
      if (action === 'ptt') {
        event.preventDefault();
        event.stopImmediatePropagation();
      }
    }
    if (event.code === 'Space' || event.code === 'Tab' || event.code.startsWith('Arrow')) event.preventDefault();
  };
  windowObject.addEventListener('keydown', (event) => handle(event, true), keyboardOptions);
  windowObject.addEventListener('keyup', (event) => handle(event, false), keyboardOptions);
  windowObject.addEventListener('blur', () => router.releaseAll(), options);
  canvas.addEventListener('pointerdown', () => canvas.focus({ preventScroll: true }), options);
  return () => {
    controller.abort();
    router.releaseAll();
  };
}
