// retoor <retoor@molodetz.nl>
import Queue from "./queue.js";
import Animator from "./animator.js";
import Balloon from "./balloon.js";
import { ensureStylesheet } from "./styles.js";

export default class Agent {
  constructor(mapUrl, data, sounds) {
    ensureStylesheet();

    this._queue = new Queue(this._onQueueEmpty.bind(this));

    this._el = document.createElement("div");
    this._el.className = "md-clippy-agent";

    document.body.appendChild(this._el);

    this._animator = new Animator(this._el, mapUrl, data, sounds);
    this._balloon = new Balloon(this._el);
    this._tts = data.tts;

    this._setupEvents();
  }

  gestureAt(x, y) {
    const direction = this._getDirection(x, y);
    const gestureAnim = "Gesture" + direction;
    const lookAnim = "Look" + direction;

    const animation = this.hasAnimation(gestureAnim) ? gestureAnim : lookAnim;
    return this.play(animation);
  }

  hide(fast, callback) {
    this._hidden = true;
    const el = this._el;
    this.stop();
    if (fast) {
      this._el.style.display = "none";
      this.stop();
      this.pause();
      if (callback) callback();
      return;
    }

    return this._playInternal("Hide", function () {
      el.style.display = "none";
      this.pause();
      if (callback) callback();
    });
  }

  moveTo(x, y, duration) {
    const direction = this._getDirection(x, y);
    const anim = "Move" + direction;
    if (duration === undefined) duration = 1000;

    this._addToQueue(function (complete) {
      const clamped = this._clampXY(x, y);
      const cx = clamped.x;
      const cy = clamped.y;

      if (duration === 0) {
        this._el.style.top = cy + "px";
        this._el.style.left = cx + "px";
        this.reposition();
        complete();
        return;
      }

      if (!this.hasAnimation(anim)) {
        this._animate(this._el, { top: cy, left: cx }, duration, complete);
        return;
      }

      const callback = (name, state) => {
        if (state === Animator.States.EXITED) {
          complete();
        }
        if (state === Animator.States.WAITING) {
          this._animate(this._el, { top: cy, left: cx }, duration, () => {
            this._animator.exitAnimation();
          });
        }
      };

      this._playInternal(anim, callback);
    }, this);
  }

  _animate(element, props, duration, callback) {
    const start = performance.now();
    const startProps = {};

    for (const prop in props) {
      const currentValue = parseFloat(getComputedStyle(element)[prop]) || 0;
      startProps[prop] = currentValue;
    }

    const swing = (p) => 0.5 - Math.cos(p * Math.PI) / 2;

    const animate = (currentTime) => {
      const elapsed = currentTime - start;
      const progress = Math.min(elapsed / duration, 1);
      const eased = swing(progress);

      for (const prop in props) {
        const startValue = startProps[prop];
        const endValue = props[prop];
        const currentValue = startValue + (endValue - startValue) * eased;
        element.style[prop] = currentValue + "px";
      }

      if (progress < 1) {
        requestAnimationFrame(animate);
      } else if (callback) {
        callback();
      }
    };

    requestAnimationFrame(animate);
  }

  _playInternal(animation, callback) {
    if (this._isIdleAnimation() && this._idlePromise) {
      this._idlePromise.then(() => {
        this._playInternal(animation, callback);
      });
      return;
    }

    this._animator.showAnimation(animation, callback);
  }

  play(animation, timeout, cb) {
    if (!this.hasAnimation(animation)) return false;

    if (timeout === undefined) timeout = 5000;

    this._addToQueue(function (complete) {
      let completed = false;

      const callback = function (name, state) {
        if (state === Animator.States.EXITED) {
          completed = true;
          if (cb) cb();
          complete();
        }
      };

      if (timeout) {
        window.setTimeout(() => {
          if (completed) return;
          this._animator.exitAnimation();
        }, timeout);
      }

      this._playInternal(animation, callback);
    }, this);

    return true;
  }

  show(fast) {
    this._hidden = false;
    if (fast) {
      this._el.style.display = "block";
      this.resume();
      this._onQueueEmpty();
      return;
    }

    const style = getComputedStyle(this._el);

    if (style.top === "auto" || style.left === "auto") {
      const left = (window.innerWidth - this._el.offsetWidth) / 2;
      const top = 40;
      const clamped = this._clampXY(left, top);
      this._el.style.top = clamped.y + "px";
      this._el.style.left = clamped.x + "px";
    }

    this._el.style.display = "block";
    this.resume();
    return this.play("Show");
  }

  speak(text, options) {
    this._addToQueue(function (complete) {
      this._balloon.speak(complete, text, options && options.hold);
      if (options && options.tts) this._speakTTS(text);
    }, this);
  }

  async speakStream(source, options) {
    this.stop();

    let text = "";
    const stream = this._balloon.speakStream(() => {
      this._onQueueEmpty();
    });

    for await (const chunk of source) {
      text += chunk;
      stream.push(chunk);
    }

    if (options && options.tts && text) this._speakTTS(text);
    stream.done();
  }

  closeBalloon() {
    this._balloon.hide();
  }

  delay(time) {
    time = time || 250;

    this._addToQueue(function (complete) {
      this._onQueueEmpty();
      window.setTimeout(complete, time);
    });
  }

  stopCurrent() {
    this._animator.exitAnimation();
    this._balloon.close();
  }

  stop() {
    this._queue.clear();
    this._animator.exitAnimation();
    this._balloon.hide();
    if (this._tts && "speechSynthesis" in window) {
      speechSynthesis.cancel();
    }
  }

  hasAnimation(name) {
    return this._animator.hasAnimation(name);
  }

  animations() {
    return this._animator.animations();
  }

  animate() {
    const animations = this.animations();
    const anim = animations[Math.floor(Math.random() * animations.length)];

    if (anim.indexOf("Idle") === 0) {
      return this.animate();
    }
    return this.play(anim);
  }

  _getDirection(x, y) {
    const rect = this._el.getBoundingClientRect();
    const h = this._el.offsetHeight;
    const w = this._el.offsetWidth;

    const centerX = rect.left + w / 2;
    const centerY = rect.top + h / 2;

    const a = centerY - y;
    const b = centerX - x;

    const r = Math.round((180 * Math.atan2(a, b)) / Math.PI);

    if (-45 <= r && r < 45) return "Right";
    if (45 <= r && r < 135) return "Up";
    if ((135 <= r && r <= 180) || (-180 <= r && r < -135)) return "Left";
    if (-135 <= r && r < -45) return "Down";

    return "Top";
  }

  _onQueueEmpty() {
    if (this._hidden || this._isIdleAnimation()) return;

    const idleAnim = this._getIdleAnimation();
    this._idlePromise = new Promise((resolve) => {
      this._idleResolve = resolve;
    });

    this._animator.showAnimation(idleAnim, this._onIdleComplete.bind(this));
  }

  _onIdleComplete(name, state) {
    if (state === Animator.States.EXITED) {
      if (this._idleResolve) {
        this._idleResolve();
      }
      this._idlePromise = null;
      this._idleResolve = null;
    }
  }

  _isIdleAnimation() {
    const c = this._animator.currentAnimationName;
    return c && c.indexOf("Idle") === 0;
  }

  _getIdleAnimation() {
    const animations = this.animations();
    const idle = [];
    for (let i = 0; i < animations.length; i++) {
      const a = animations[i];
      if (a.indexOf("Idle") === 0) {
        idle.push(a);
      }
    }

    const idx = Math.floor(Math.random() * idle.length);
    return idle[idx];
  }

  _setupEvents() {
    this._resizeHandle = this.reposition.bind(this);
    this._pointerDownHandle = this._onPointerDown.bind(this);
    this._dblClickHandle = this._onDoubleClick.bind(this);
    window.addEventListener("resize", this._resizeHandle);
    this._el.addEventListener("mousedown", this._pointerDownHandle);
    this._el.addEventListener("touchstart", this._pointerDownHandle, { passive: false });
    this._el.addEventListener("dblclick", this._dblClickHandle);
  }

  _onDoubleClick() {
    if (!this.play("ClickedOn")) {
      this.animate();
    }
  }

  reposition() {
    const style = getComputedStyle(this._el);
    if (style.display === "none") return;
    if (style.visibility === "hidden") return;
    if (style.width === "0" || style.height === "0") return;

    const o = this._el.getBoundingClientRect();
    const bH = this._el.offsetHeight;
    const bW = this._el.offsetWidth;

    const wW = window.innerWidth;
    const wH = window.innerHeight;

    let top = o.top;
    let left = o.left;
    const m = 5;

    if (top - m < 0) {
      top = m;
    } else if (top + bH + m > wH) {
      top = wH - bH - m;
    }

    if (left - m < 0) {
      left = m;
    } else if (left + bW + m > wW) {
      left = wW - bW - m;
    }

    this._el.style.left = left + "px";
    this._el.style.top = top + "px";
    this._balloon.reposition();
  }

  _onPointerDown(e) {
    e.preventDefault();
    this._startDrag(e);
  }

  _startDrag(e) {
    this.pause();
    this._balloon.hide(true);
    this._offset = this._calculateClickOffset(e);

    this._moveHandle = this._dragMove.bind(this);
    this._upHandle = this._finishDrag.bind(this);

    window.addEventListener("mousemove", this._moveHandle);
    window.addEventListener("mouseup", this._upHandle);
    window.addEventListener("touchmove", this._moveHandle, { passive: false });
    window.addEventListener("touchend", this._upHandle);

    this._dragUpdateLoop = window.setTimeout(this._updateLocation.bind(this), 10);
  }

  _calculateClickOffset(e) {
    const point = "touches" in e ? e.touches[0] : e;
    const o = this._el.getBoundingClientRect();
    return {
      top: point.pageY - (o.top + window.pageYOffset),
      left: point.pageX - (o.left + window.pageXOffset),
    };
  }

  _updateLocation() {
    this._el.style.top = this._targetY + "px";
    this._el.style.left = this._targetX + "px";
    this._dragUpdateLoop = window.setTimeout(this._updateLocation.bind(this), 10);
  }

  _dragMove(e) {
    e.preventDefault();
    const point = "touches" in e ? e.touches[0] : e;
    const x = point.clientX - this._offset.left;
    const y = point.clientY - this._offset.top;
    this._targetX = x;
    this._targetY = y;
    this._clampTarget();
  }

  _finishDrag() {
    window.clearTimeout(this._dragUpdateLoop);
    window.removeEventListener("mousemove", this._moveHandle);
    window.removeEventListener("mouseup", this._upHandle);
    window.removeEventListener("touchmove", this._moveHandle);
    window.removeEventListener("touchend", this._upHandle);

    this._balloon.show();
    this.reposition();
    this.resume();
  }

  _clampTarget() {
    const m = 5;
    const bW = this._el.offsetWidth;
    const bH = this._el.offsetHeight;
    const wW = window.innerWidth;
    const wH = window.innerHeight;

    this._targetX = Math.max(m, Math.min(this._targetX, wW - bW - m));
    this._targetY = Math.max(m, Math.min(this._targetY, wH - bH - m));
  }

  _clampXY(x, y) {
    const m = 5;
    const bW = this._el.offsetWidth;
    const bH = this._el.offsetHeight;
    const wW = window.innerWidth;
    const wH = window.innerHeight;

    return {
      x: Math.max(m, Math.min(x, wW - bW - m)),
      y: Math.max(m, Math.min(y, wH - bH - m)),
    };
  }

  _addToQueue(func, scope) {
    if (scope) func = func.bind(scope);
    this._queue.queue(func);
  }

  _speakTTS(text) {
    if (!this._tts || !("speechSynthesis" in window)) return;
    speechSynthesis.cancel();
    const utterance = new SpeechSynthesisUtterance(text.replaceAll("\n", " "));
    utterance.rate = this._tts.rate;
    utterance.pitch = this._tts.pitch;
    const voices = speechSynthesis.getVoices();
    if (voices.length > 0) {
      const match = voices.find((v) => v.name.includes(this._tts.voice));
      if (match) utterance.voice = match;
      speechSynthesis.speak(utterance);
    } else {
      speechSynthesis.addEventListener(
        "voiceschanged",
        () => {
          const v = speechSynthesis.getVoices();
          const match = v.find((voice) => voice.name.includes(this._tts.voice));
          if (match) utterance.voice = match;
          speechSynthesis.speak(utterance);
        },
        { once: true },
      );
    }
  }

  dispose() {
    this.stop();
    window.removeEventListener("resize", this._resizeHandle);
    window.clearTimeout(this._dragUpdateLoop);
    window.removeEventListener("mousemove", this._moveHandle);
    window.removeEventListener("mouseup", this._upHandle);
    window.removeEventListener("touchmove", this._moveHandle);
    window.removeEventListener("touchend", this._upHandle);
    this._animator.dispose();
    this._balloon.dispose();
    this._queue.dispose();
    this._el.remove();
  }

  pause() {
    this._animator.pause();
    this._balloon.pause();
  }

  resume() {
    this._animator.resume();
    this._balloon.resume();
  }
}

export async function initAgent(loaders) {
  const [{ default: data }, { default: map }, sounds] = await Promise.all([
    loaders.agent(),
    loaders.map(),
    _loadSounds(loaders),
  ]);
  return new Agent(map, data, sounds);
}

async function _loadSounds(loaders) {
  const audio = document.createElement("audio");
  const canPlayMp3 = !!audio.canPlayType && audio.canPlayType("audio/mp3") !== "";
  if (!canPlayMp3) return {};
  const m = await loaders.sound();
  return m.default;
}
