# 記事末尾の❤️の押し方を解釈する。ブラウザではPicoRuby.wasmが、
# テストではPicoRuby VMが同じこのファイルを読む。

# 調整値の単一の置き場所
module HeartPolicy
  HOLD_INTERVAL_MS = 90
  IDLE_BEFORE_POST_MS = 1000
  MAX_DELTA_PER_POST = 50
  # 1回のtickでまとめて出す上限。背面タブでスケジューラが絞られると
  # 経過時間がいくらでも伸びるので、ここで頭を打つ
  MAX_CATCHUP = 3
  # 押しっぱなしを続けられる長さ。pointerupを取りこぼしても、これを過ぎたら
  # 自分で解除する
  MAX_HOLD_MS = 10_000
end

# 押し方の解釈。JSに触らないので、VMさえあればテストできる。
class HeartState
  def initialize
    @held = false
    @held_since = 0
    @next_hold_at = 0
    @pending = 0
    @last_input_at = 0
  end

  def press(now_ms)
    @held = true
    @held_since = now_ms
    @next_hold_at = now_ms + HeartPolicy::HOLD_INTERVAL_MS
    emit(1, now_ms)
  end

  def release(now_ms)
    @held = false
    @last_input_at = now_ms
    0
  end

  # 押しっぱなしの発生。tickが遅れたぶんは経過した間隔の数だけまとめて出すが、
  # 上限で頭を打つ。押しっぱなし自体もMAX_HOLD_MSで自分から解除する
  def tick(now_ms)
    return 0 unless @held

    if now_ms - @held_since >= HeartPolicy::MAX_HOLD_MS
      release(now_ms)
      return 0
    end

    return 0 if now_ms < @next_hold_at

    count = ((now_ms - @next_hold_at) / HeartPolicy::HOLD_INTERVAL_MS) + 1
    if count > HeartPolicy::MAX_CATCHUP
      count = HeartPolicy::MAX_CATCHUP
      # 遅れを持ち越さない。次のtickは通常の間隔で来る
      @next_hold_at = now_ms + HeartPolicy::HOLD_INTERVAL_MS
    else
      @next_hold_at += count * HeartPolicy::HOLD_INTERVAL_MS
    end
    emit(count, now_ms)
  end

  # 起動が終わるまでのタップを引き取る。エフェクトはJSがすでに出しているので
  # 記録だけ積み、押下状態には触らない
  def record(count, now_ms)
    return if count == 0

    @pending += count
    @last_input_at = now_ms
  end

  def due_delta(now_ms)
    return 0 if @held
    return 0 if @pending == 0
    return 0 if now_ms - @last_input_at < HeartPolicy::IDLE_BEFORE_POST_MS

    @pending < HeartPolicy::MAX_DELTA_PER_POST ? @pending : HeartPolicy::MAX_DELTA_PER_POST
  end

  def sent(delta)
    @pending -= delta
    @pending = 0 if @pending < 0
  end

  private

  def emit(count, now_ms)
    @pending += count
    @last_input_at = now_ms
    count
  end
end

# JSへの唯一の出口。ここだけがJSに触る
module JSBridge
  def self.spawn(count, mine)
    return if count == 0

    JS.global[:heartField].spawn(count, mine)
  end

  # 送りっぱなし。失敗してもエフェクトは止まらない
  def self.post(path, delta)
    JS.global.__heartPost(path, delta)
    nil
  end

  def self.now_ms
    JS.global[:performance].now.to_i
  end

  # 以降のタップはRubyが拾うとJSへ伝え、起動中に溜まったタップ数を受け取る
  def self.ruby_ready
    JS.global.__heartRubyReady().to_i
  end
end

# サーバへ送る口。
#
# SPAのビルドでは`Heart`(app/funicular/models/heart.rb)が居るので、Funicularの
# `Model`を通る。**ブラウザもエッジもRubyで、契約は同じ属性名**になり、送る前の
# 検査がクライアント側でも走る。単体のページにはapp.mrbが無く`Heart`が居ない
# ので、JSのfetch(heart-boot.jsの`__heartPost`)へ落ちる。
#
# Modelの経路は非同期(コールバック)。1本流れている間は次を送らない。送らな
# かったぶんは`state.sent`を呼んでいないので`@pending`に残り、次のtickで出る。
module HeartSender
  def self.model?
    defined?(Heart) ? true : false
  end

  # 送れる状態か。JSの経路は同期なので常にtrue
  def self.ready?
    return true unless model?

    !@in_flight
  end

  # 送りっぱなし。返ってくる総数は使わない(他者が押した数を飛ばす仕様は
  # やめた。数はD1で見る)。Modelの経路では次を送る前に1件を待つ
  def self.send(path, delta)
    unless model?
      JSBridge.post(path, delta)
      return
    end

    @in_flight = true
    Heart.create({ "path" => path, "delta" => delta }) do |_heart, _error|
      @in_flight = false
    end
  end
end

# ボタンとの結び直し。
#
# 単体のページではボタンは最後まで同じDOMだが、SPA化(issue #21)を入れると
# 遷移のたびにcomponentが描き直してボタンが別のDOMになる。押し方の解釈
# (HeartState)はそのままに、ボタンとpathだけを繋ぎ替える。
#
# 記事の無いページ(一覧)ではボタンが無い。そのときはpathをnilにして、
# ループは何も送らない。
module HeartRuntime
  def self.state
    @state ||= HeartState.new
  end

  def self.path
    @path
  end

  # 繋がったらtrue。SPAはcomponentのmountのたびにこれを呼ぶ。
  def self.bind
    button = JS.document.querySelector(".heart")
    unless button
      @path = nil
      return false
    end

    # 同じボタンへ二重にリスナーを付けない
    return true if button[:dataset][:heartBound].to_s == "1"

    button[:dataset][:heartBound] = "1"
    @path = button[:dataset][:path].to_s
    button.addEventListener("pointerdown") do
      JSBridge.spawn(state.press(JSBridge.now_ms), true)
    end
    true
  end

  # 押しっぱなしの解除はwindowで拾う。ボタンの上で指を離すとは限らず、外へ
  # 流れた場合にボタンのpointerupは来ない。背面へ回ったときも解く。
  # ボタンと違って差し替わらないので、一度だけ張る。
  def self.watch_release
    JS.global.addEventListener("pointerup") { state.release(JSBridge.now_ms) }
    JS.global.addEventListener("pointercancel") { state.release(JSBridge.now_ms) }
    JS.global.addEventListener("blur") { state.release(JSBridge.now_ms) }
    JS.document.addEventListener("visibilitychange") { state.release(JSBridge.now_ms) }
  end
end

# ブラウザでだけ動く。テストのVMにはJSが無いので、ここから下は走らない
if defined?(JS)
  state = HeartRuntime.state
  HeartRuntime.bind
  HeartRuntime.watch_release

  # 起動中のタップをJSから引き取る。エフェクトはJSがすでに出しているので
  # 記録だけ積む。これ以降のタップは上のリスナーが拾う
  state.record(JSBridge.ruby_ready, JSBridge.now_ms)

  loop do
    now = JSBridge.now_ms
    JSBridge.spawn(state.tick(now), true)

    delta = state.due_delta(now)
    path = HeartRuntime.path
    if delta > 0 && path && HeartSender.ready?
      state.sent(delta)
      HeartSender.send(path, delta)
    end

    sleep_ms HeartPolicy::HOLD_INTERVAL_MS / 3
  end
end
