# main.base.rb — JS / Three.js ポーティング層
#
# JS への接触をこのファイルに閉じ込めるための薄いグローバルメソッド群。
# 各メソッドは単一の interop 操作だけを包む。
# グローバルの THREE と RT は index.js が用意する。
#
# interop の規約:
#   - 真偽判定：JS の boolean / null / undefined は Ruby では JS::Object であり if では常に真になる。判定は == JS::True / JS::False / JS::Null / JS::Undefined で行う。
#   - vendor prefix：webkitAudioContext などはフォールバックで拾い、両方欠ける非対応環境では明示的に raise する（new で不明瞭に落とさない）。
#   - バイナリ往復：任意バイナリ（wav, png など）は JS 側の ArrayBuffer / Blob に置いたまま渡し、Ruby String との往復（文字列エンコーディング変換）を避ける。
#   - await は boot 限定：fetch / decodeAudioData / createImageBitmap など await を含む口は起動時のトップレベル（evalAsync の文脈）に集約し、ループやハンドラからは同期 API のみ呼ぶ（ruby.wasm issue #459）。

require "js"

# === THREE ファクトリ ===
def js_three_scene                  = JS.global[:THREE][:Scene].new
def js_three_sprite_material        = JS.global[:THREE][:SpriteMaterial].new
def js_three_sprite(material)       = JS.global[:THREE][:Sprite].new(material)
def js_three_texture_loader         = JS.global[:THREE][:TextureLoader].new
def js_three_canvas_texture(canvas) = JS.global[:THREE][:CanvasTexture].new(canvas)

def js_three_ortho_camera(left, right, top, bottom, near, far)
  JS.global[:THREE][:OrthographicCamera].new(left, right, top, bottom, near, far)
end

# === テクスチャ / マテリアル ===
def js_load_texture(loader, path, &on_load) = loader.load(path.to_s, &on_load)

def js_texture_size(texture) = [texture[:image][:width].to_i, texture[:image][:height].to_i]

# ドット絵用
def js_texture_nearest(texture)
  texture[:magFilter] = JS.global[:THREE][:NearestFilter]
  texture[:minFilter] = JS.global[:THREE][:NearestFilter]
  texture
end

# 文字用
def js_texture_linear_srgb(texture)
  texture[:magFilter]   = JS.global[:THREE][:LinearFilter]
  texture[:minFilter]   = JS.global[:THREE][:LinearFilter]
  texture[:colorSpace]  = JS.global[:THREE][:SRGBColorSpace]
  texture[:needsUpdate] = true
  texture
end

def js_texture_dispose(texture)   = texture.dispose
def js_material_dispose(material) = material.dispose

def js_material_set_map(material, texture)
  material[:map] = texture
  material[:needsUpdate] = true
end

# === シーングラフ ===
def js_scene_add(scene, node)    = scene.add(node)
def js_scene_remove(scene, node) = scene.remove(node)

# === レンダラ ===
def js_rt_set_size(width, height)     = JS.global[:RT].setSize(width, height)
def js_rt_set_clear_color(hex, alpha) = JS.global[:RT].setClearColor(hex, alpha)
def js_rt_render(scene, camera)       = JS.global[:RT].render(scene, camera)

# === プラットフォーム / DOM ===
def js_request_animation_frame(&block) = JS.global.requestAnimationFrame { |time| block.call(time.to_f) }

def js_get_element_by_id(id)     = JS.global[:document].getElementById(id)
def js_element_missing?(element) = element.nil? || element == JS::Null || element == JS::Undefined
def js_create_canvas             = JS.global[:document].createElement("canvas")
def js_canvas_context_2d(canvas) = canvas.getContext("2d")

def js_device_pixel_ratio
  ratio = JS.global[:devicePixelRatio].to_f
  ratio > 0 ? ratio : 1.0
end

def js_console_error(message) = JS.global[:console].error(message.to_s)

# === イベント ===
def js_add_window_listener(type, &block)           = JS.global[:window].addEventListener(type, &block)
def js_add_element_listener(element, type, &block) = element.addEventListener(type, &block)

def js_event_code(event)            = event[:code].to_s
def js_event_button(event)          = event[:button].to_i
def js_event_client_xy(event)       = [event[:clientX].to_f, event[:clientY].to_f]
def js_event_prevent_default(event) = event.preventDefault

def js_element_left_top(element)
  rectangle = element.getBoundingClientRect
  [rectangle[:left].to_f, rectangle[:top].to_f]
end

# ============================================================
# 以下は 3D 用（public/3d/index.js が用意する ADDONS も前提に加わる）
# ============================================================

# === THREE 3D ファクトリ ===
def js_three_plane_geometry(width, height) = JS.global[:THREE][:PlaneGeometry].new(width, height)
def js_three_mesh_phong_material           = JS.global[:THREE][:MeshPhongMaterial].new
def js_three_mesh(geometry, material)      = JS.global[:THREE][:Mesh].new(geometry, material)
def js_three_fog(hex, near, far)           = JS.global[:THREE][:Fog].new(hex, near, far)
def js_three_animation_mixer(root)         = JS.global[:THREE][:AnimationMixer].new(root)

def js_three_perspective_camera(fov, aspect, near, far)
  JS.global[:THREE][:PerspectiveCamera].new(fov, aspect, near, far)
end

def js_three_hemisphere_light(sky_hex, ground_hex, intensity)
  JS.global[:THREE][:HemisphereLight].new(sky_hex, ground_hex, intensity)
end

def js_three_directional_light(hex, intensity)
  JS.global[:THREE][:DirectionalLight].new(hex, intensity)
end

def js_three_grid_helper(size, divisions, hex1, hex2)
  JS.global[:THREE][:GridHelper].new(size, divisions, hex1, hex2)
end

# === GLTF / addon ===
def js_gltf_loader = JS.global[:ADDONS][:GLTFLoader].new

# onError は ADDONS.loadGLTF（public/3d/index.js）が console.error に固定する
def js_gltf_load(loader, path, &on_load) = JS.global[:ADDONS].loadGLTF(loader, path.to_s, &on_load)

def js_gltf_scene(gltf)            = gltf[:scene]
def js_gltf_animations(gltf)       = gltf[:animations]
def js_skeleton_clone(node)        = JS.global[:ADDONS].cloneSkeleton(node)
def js_material_textures(material) = JS.global[:ADDONS].materialTextures(material)

# === JS 値ユーティリティ ===
def js_array_length(array)    = array[:length].to_i
def js_array_at(array, index) = array[index]
def js_array?(value)          = JS.global[:Array].isArray(value) == JS::True
def js_same?(a, b)            = JS.global[:Object].is(a, b) == JS::True

# === Object3D / シーン ===
def js_node_traverse(node, &block) = node.traverse(&block)
def js_node_dispose(node)          = node.dispose # GridHelper / Light など dispose を持つ物のみ
def js_mesh?(node)                 = node[:isMesh] == JS::True
def js_skinned_mesh?(node)         = node[:isSkinnedMesh] == JS::True
def js_skeleton_dispose(node)      = node[:skeleton].dispose
def js_geometry_dispose(geometry)  = geometry.dispose
def js_color_set(object, hex)      = object[:color].set(hex) # Color 型は代入でなく set
def js_scene_set_fog(scene, fog)   = scene[:fog] = fog

# === レンダラ統計 ===
# 開発時のリーク監視の口。保持中の資源数（シーン切替で単調増加すれば解放漏れ）
def js_rt_info_counts
  info = JS.global[:RT][:renderer][:info]
  {
    geometries: info[:memory][:geometries].to_i,
    textures:   info[:memory][:textures].to_i,
    programs:   info[:programs][:length].to_i,
    calls:      info[:render][:calls].to_i,
  }
end

# === カメラ ===
def js_camera_look_at(camera, x, y, z) = camera.lookAt(x, y, z)

def js_camera_set_aspect(camera, aspect)
  camera[:aspect] = aspect
  camera.updateProjectionMatrix
end

# === アニメーション ===
def js_mixer_update(mixer, dt)          = mixer.update(dt)
def js_mixer_clip_action(mixer, clip)   = mixer.clipAction(clip)
def js_mixer_stop_all(mixer)            = mixer.stopAllAction
def js_mixer_on_finished(mixer, &block) = mixer.addEventListener("finished", &block)
def js_event_action(event)              = event[:action]
def js_clip_name(clip)                  = clip[:name].to_s

def js_action_play(action)                  = action.play
def js_action_reset(action)                 = action.reset
def js_action_fade_in(action, seconds)      = action.fadeIn(seconds)
def js_action_fade_out(action, seconds)     = action.fadeOut(seconds)
def js_action_set_weight(action, weight)    = action.setEffectiveWeight(weight)
def js_action_set_time_scale(action, scale) = action.setEffectiveTimeScale(scale)
def js_action_set_enabled(action, flag)     = action[:enabled] = flag
def js_action_time(action)                  = action[:time].to_f
def js_action_set_time(action, time)        = action[:time] = time

def js_clip_duration(clip) = clip[:duration].to_f

# Ruby の Object#clone と衝突するため call 経由で JS の clone() を呼ぶ
def js_clip_clone(clip) = clip.call(:clone)

# 1 回再生用。終端姿勢で止める
def js_action_loop_once(action)
  action[:loop] = JS.global[:THREE][:LoopOnce]
  action[:clampWhenFinished] = true
end

# ============================================================
# 取得（fetch）/ バイト列 / Web Audio の interop。
# THREE 以外のブラウザ API への接触をここにまとめる。
# 各メソッドは単一の interop 操作だけを包む（上の THREE 群と同じ方針）。
# fetch / decodeAudioData / createImageBitmap は await を含むため、起動時のトップレベルからのみ呼べる（冒頭「await は boot 限定」の規約を参照）。
# ============================================================

# === fetch（Http が包む） ===
# fetch は Promise を返すため await する。起動時の取得文脈からのみ呼べる。
def js_fetch(url)                     = JS.global.fetch(url.to_s).await
def js_response_status(response)      = response[:status].to_i
def js_response_ok?(response)         = response[:ok] == JS::True
def js_response_header(response, name) = response[:headers].get(name.to_s)
def js_response_array_buffer(response) = response.arrayBuffer.await

# === バイト列（ArrayBuffer / TypedArray / Blob） ===
# decodeAudioData は入力 ArrayBuffer を detach する。複製を渡せば元が残る。
def js_array_buffer_copy(buffer)        = buffer.call(:slice, 0)
def js_array_buffer_byte_length(buffer) = buffer[:byteLength].to_i
def js_uint8_array(length)              = JS.global[:Uint8Array].new(length)
def js_uint8_view(buffer)               = JS.global[:Uint8Array].new(buffer)
def js_typed_at(typed, index)           = typed[index].to_i
def js_typed_set(typed, index, value)   = typed[index] = value
def js_typed_buffer(typed)              = typed[:buffer]

def js_blob(array_buffer, mime)
  parts = JS.global[:Array].new
  parts.call(:push, array_buffer)
  options = JS.global[:Object].new
  options[:type] = mime.to_s
  JS.global[:Blob].new(parts, options)
end

def js_object_url(blob)   = JS.global[:URL].createObjectURL(blob)
def js_revoke_url(url)    = JS.global[:URL].revokeObjectURL(url.to_s)

# createImageBitmap は Promise を返すため、await を要し起動時の取得文脈からのみ呼べる。
# ImageBitmap は WebGL の UNPACK_FLIP_Y が効かないため、生成時に上下反転しておき（imageOrientation: flipY）、texture[:flipY]=false と対で使ってスプライトの座標系に合わせる。
def js_create_image_bitmap(blob)
  options = JS.global[:Object].new
  options[:imageOrientation] = "flipY"
  options[:premultiplyAlpha] = "none"
  JS.global.createImageBitmap(blob, options).await
end
def js_image_bitmap_size(bitmap) = [bitmap[:width].to_i, bitmap[:height].to_i]
def js_texture_no_flip(texture)  = texture[:flipY] = JS::False

# === Web Audio ===
def js_audio_context
  klass = JS.global[:AudioContext]
  klass = JS.global[:webkitAudioContext] if klass == JS::Undefined || klass.nil?
  # 両方欠ける古環境では klass が Undefined のまま new で不明瞭に落ちるため、ここで明示する。
  raise "Web Audio 非対応の環境です（AudioContext が見つかりません）" if klass == JS::Undefined || klass.nil?
  klass.new
end

def js_audio_state(context)        = context[:state].to_s
def js_audio_resume(context)       = context.resume # Promise を返すが解錠には await 不要
def js_audio_current_time(context) = context[:currentTime].to_f
def js_audio_destination(context)  = context[:destination]

# decodeAudioData は Promise を返す。await のため起動時の取得文脈からのみ呼べる。
def js_audio_decode(context, array_buffer) = context.decodeAudioData(array_buffer).await
def js_audio_buffer_duration(buffer)       = buffer[:duration].to_f

def js_audio_gain(context)            = context.createGain
def js_audio_gain_set(gain, value)    = gain[:gain][:value] = value
def js_audio_buffer_source(context)   = context.createBufferSource
def js_audio_source_buffer(source, b) = source[:buffer] = b
def js_audio_connect(node, dest)      = node.connect(dest)
def js_audio_source_start(source)     = source.start(0)
def js_audio_source_stop(source)      = source.stop
# ended は同期ブロックのみ（ブロック内で await すると issue #459 に当たる）
def js_audio_on_ended(source, &block) = source.addEventListener("ended", &block)

# iOS / Safari 旧版向けの無音バッファ解錠。ジェスチャ内で 1 度だけ叩く
def js_audio_silent_ping(context)
  buffer = context.createBuffer(1, 1, 22050)
  source = context.createBufferSource
  source[:buffer] = buffer
  source.connect(context[:destination])
  source.start(0)
end

# ============================================================
# 標準ライブラリ層（取得 / バイト列 / 音声）。
# 上の interop を束ねた薄いクラス群。2D / 3D が共有するため base に置く。
# Http.get / Sound.load など fetch・decodeAudioData を伴う口は、起動時のトップレベルからのみ呼べる（冒頭「await は boot 限定」の規約を参照）。
# ============================================================

# バイト列のプリミティブ。
# 出自（パスかバイト列か）を隠し、mime を伴うバイト列を保持して、消費者（Picture / Sound）が要る形を遅延で取り出させる。
# 正本は JS の ArrayBuffer（JS::Object）として不変に持つ。
# ruby.wasm の Ruby String と JS string の変換は文字列エンコーディングを介すため、任意バイナリ（wav や png）を Ruby String で往復させると壊れる。
# 取得もデコードも Blob 生成も JS 側の API なので、バイト列は JS 側に置いたまま渡す。
class Resource
  attr_reader :mime

  # 取得経路（Http）が fetch の ArrayBuffer から内部で構築する入口。
  def self.from_array_buffer(array_buffer, mime:)
    new(array_buffer, mime)
  end

  # Ruby 側で組み立てたバイト列を取り込む入口。Ruby String → JS の ArrayBuffer へ写す。
  # 既定の取得経路（Http）は fetch の ArrayBuffer から直接 Resource を作るのでこの口は通らない。
  # ブラウザ外で生成・加工したバイト列を Resource に載せたいとき用に残す。
  def self.from_bytes(bytes, mime:)
    length = bytes.bytesize
    typed = js_uint8_array(length)
    bytes.each_byte.with_index { |byte, index| js_typed_set(typed, index, byte) }
    new(js_typed_buffer(typed), mime)
  end

  # JS の ArrayBuffer。detach 対策で呼ぶたびに複製（slice）を返す。
  # decodeAudioData は入力 ArrayBuffer を detach するため、正本は渡さない。
  def array_buffer
    js_array_buffer_copy(@array_buffer)
  end

  # JS の Blob。createImageBitmap や createObjectURL の材料。
  def blob
    js_blob(array_buffer, @mime)
  end

  # Ruby String 化。コピーが要るため要求時のみ。
  def bytes
    view = js_uint8_view(@array_buffer)
    length = js_array_buffer_byte_length(@array_buffer)
    (0...length).map { |index| js_typed_at(view, index) }.pack("C*")
  end

  def byte_length
    js_array_buffer_byte_length(@array_buffer)
  end

  private

  def initialize(array_buffer, mime)
    @array_buffer = array_buffer
    @mime = mime
  end
end

# 取得層。
# ruby.wasm には Net::HTTP が無く、ネットワークはブラウザの fetch を介す。
# そこで Http が fetch を包み、Net::HTTP の二段構えに倣って取得する。
#   Http.get(url)          #=> Resource   # body だけ。Net::HTTP.get 相当
#   Http.get_response(url) #=> Response    # status や headers も要るとき
# fetch は Promise を返すため、Http は await を要する。
# 起動時の取得文脈（evalAsync のトップレベル）からのみ呼べる（ゲームループやイベントハンドラ不可）。
class Http
  def self.get(url)          = get_response(url).body
  def self.get_response(url) = Response.new(js_fetch(url))
end

# 薄いレスポンス。status・headers・body・ok?（2xx 判定）だけを持つ。
# body は Resource を返し、その mime は Content-Type ヘッダから取る。
# fetch の body は一度しか読めないため、body は一度読んで Resource に確定させる。
class Response
  def initialize(js_response)
    @response = js_response
    @body = nil
    @body_read = false
  end

  def status = js_response_status(@response)
  def ok?    = js_response_ok?(@response)

  def header(name)
    value = js_response_header(@response, name)
    return nil if value.nil? || value == JS::Null || value == JS::Undefined
    value.to_s
  end

  def body
    return @body if @body_read
    @body_read = true
    array_buffer = js_response_array_buffer(@response)
    @body = Resource.from_array_buffer(array_buffer, mime: header("content-type"))
  end
end

# JS の AudioContext を 1 つ包む薄いラッパ。master GainNode と autoplay 解錠を束ねる。
# クラス名は JS の AudioContext と同名だが、これはその 1:1 ラッパで、生は raw で公開する。
# ブラウザは AudioContext を数個までしか作れないため、通常はアプリに 1 つ。
class AudioContext
  attr_reader :raw, :master

  def initialize
    @raw = js_audio_context
    @master = js_audio_gain(@raw)
    js_audio_connect(@master, js_audio_destination(@raw))
    @unlocked = false
  end

  def suspended? = js_audio_state(@raw) == "suspended"

  # ブラウザの autoplay ポリシー解錠。ユーザー操作（click など）の中から 1 度だけ呼ぶ。
  # resume の Promise は待たなくてよい。Safari/iOS 旧版向けに無音バッファも鳴らす。
  def unlock
    return if @unlocked
    @unlocked = true
    js_audio_silent_ping(@raw)
    js_audio_resume(@raw)
  end

  # master の音量。0.0〜1.0 の線形振幅。
  def master_volume=(value)
    js_audio_gain_set(@master, value.to_f.clamp(0.0, 1.0))
  end
end

# 1 つの音声。デコード済み AudioBuffer を保持し、再生のたびに BufferSource を作り直す。
# AudioBufferSourceNode は使い捨て（start 後に再開不可）のため。
# 取得とデコードは await を伴うため、Sound.load は起動時の取得文脈から呼ぶ。
# 再生・停止・音量は同期で、ゲームループやイベントハンドラから呼べる。
class Sound
  DEFAULT_VOLUME = 255 # DXRuby 互換の 0〜255

  attr_reader :duration, :volume

  # path | Resource から作る。decode に await を伴うため、起動時の取得文脈から呼ぶこと。
  def self.load(source, context) = new(source, context)

  # デコード済み AudioBuffer（JS::Object）を直接包む入口。
  # await を含まないため、ゲームループやイベント文脈からも呼べる（JS 側で fetch・decodeAudioData 済みの AudioBuffer を渡すとき用）。
  def self.from_audio_buffer(buffer, context)
    new(nil, context, buffer: buffer)
  end

  # source（path | Resource）を渡すと decode する。
  # buffer を渡すとデコード済み AudioBuffer を直接包む（from_audio_buffer 経由、await なし）。
  def initialize(source, context, buffer: nil)
    @context = context
    # array_buffer は複製を返すので、decode が detach しても元は残る。
    @buffer = buffer || js_audio_decode(context.raw, coerce(source).array_buffer)
    @duration = js_audio_buffer_duration(@buffer)
    @gain = js_audio_gain(context.raw)
    js_audio_connect(@gain, context.master)
    self.volume = DEFAULT_VOLUME
    @source = nil
    @playing = false
  end

  def playing? = @playing

  # DXRuby 互換の 0〜255。DXOpal の dB 曲線（255→0dB, 0→-96dB）で振幅へ写す。
  def volume=(value)
    @volume = value.to_i.clamp(0, 255)
    normalized = @volume / 255.0
    db = (normalized - 1.0) * 96.0
    js_audio_gain_set(@gain, 10.0**(db / 20.0))
  end

  # 同期。前の再生を止めてから新しい BufferSource で鳴らす（単発主義）。
  def play
    stop
    source = js_audio_buffer_source(@context.raw)
    js_audio_source_buffer(source, @buffer)
    js_audio_connect(source, @gain)
    @source = source
    @playing = true
    # ended は自然終了でも stop でも発火する。今鳴っている source の終了だけ拾う。
    js_audio_on_ended(source) { @playing = false if @source.equal?(source) }
    js_audio_source_start(source)
    self
  end

  # 同期。冪等。
  def stop
    source = @source
    return self unless source
    @source = nil
    @playing = false
    begin
      js_audio_source_stop(source)
    rescue => error
      js_console_error("Sound#stop: #{error.class}: #{error.message}")
    end
    self
  end

  private

  # 消費者の coercion。文字列はパスとして Http.get で Resource 化、Resource はそのまま。
  # 手持ちバイト列は呼び出し側が Resource.from_bytes で包んでから渡す。
  def coerce(source)
    case source
    when Resource then source
    when String   then Http.get(source)
    else raise ArgumentError, "Sound: path（String）か Resource を渡す（#{source.class}）"
    end
  end
end
