import { ref, onUnmounted, type Ref } from 'vue'
import { synthesize } from '../services/speechService'

export type PlaySource =
  | { kind: 'tts'; text: string }
  | { kind: 'blob'; blob: Blob }
  | { kind: 'url'; url: string }
  /**
   * URL 要先问一次后端才知道（文章示范音频：缓存 miss 时后端同步合成 1-3s）。
   * 解析必须发生在 play() 内部，不能由调用方先 await 再进来 —— 只有进了 play()
   * 才算「这一次播放开始了」：前一段音频才会被 stop()，按钮才转得起圈，
   * 中途改点别的按钮时这次解析才会被 abort。
   */
  | { kind: 'lazy-url'; resolve: (signal: AbortSignal) => Promise<string> }

export interface AudioPlayer {
  /** Id of the message currently playing (audio element fired `playing`). */
  playingId: Readonly<Ref<string | null>>
  /** Id of the message whose synthesis or play() is in flight. */
  loadingId: Readonly<Ref<string | null>>
  /** Id of the message whose last playback attempt failed. Sticky until next play(). */
  errorId: Readonly<Ref<string | null>>

  play(id: string, source: PlaySource): Promise<void>
  stop(): void
}

/**
 * Single audio playback owner for the dialogue view.
 *
 * Structural guarantees:
 *  - At most one HTMLAudioElement / one in-flight synthesis at a time.
 *    Every play() begins by aborting & pausing the prior session.
 *  - Cached MP3 blobs (one per AI messageId) live in-memory; URLs are
 *    revoked on view unmount.
 *  - Errors collapse to a single per-id state. The view renders a retry
 *    affordance; clicking the same play button re-enters play().
 */
export function useAudioPlayer(): AudioPlayer {
  const playingId = ref<string | null>(null)
  const loadingId = ref<string | null>(null)
  const errorId = ref<string | null>(null)

  // Closure-private state. Not reactive on purpose.
  let currentAudio: HTMLAudioElement | null = null
  let synthAbort: AbortController | null = null

  /**
   * 已经建好的 object URL，按「这段音频是什么」缓存。重复播放同一段因此既不重新
   * 下载、也不再堆一个新的 object URL（原本每按一次播放都两样都做一遍，而 object
   * URL 要到卸载才 revoke —— 学生把一段示范听五遍就是五次下载加五条泄漏）。
   *
   * key 用**解析出来的那条远端 URL**，不用 id：`own-{idx}` 这种 id 在学生重录之后
   * 仍然是同一个，指向的却是新的 audioUrl，按 id 缓存会把上一次的录音放给他听。
   * URL 变了就是另一个 key，这个失效模式从根上不存在。tts 没有 URL，按 `tts:${id}`。
   */
  const objectUrls = new Map<string, string>()
  /** 本地录音（kind: 'blob'）没有 URL 可当 key，按 Blob 身份缓存。 */
  const blobObjectUrls = new WeakMap<Blob, string>()
  /** 建过的全部 object URL，卸载时统一 revoke。 */
  const createdUrls: string[] = []

  function trackUrl(url: string): string {
    createdUrls.push(url)
    return url
  }

  function clearCurrentAudio() {
    if (currentAudio) {
      currentAudio.onplaying = null
      currentAudio.onended = null
      currentAudio.onerror = null
      try { currentAudio.pause() } catch { /* ignore */ }
      currentAudio = null
    }
  }

  function failPlayback(id: string, reason: unknown) {
    if (loadingId.value === id) loadingId.value = null
    if (playingId.value === id) playingId.value = null
    errorId.value = id
    console.warn('[audio-player] playback failed:', id, reason)
  }

  async function play(id: string, source: PlaySource): Promise<void> {
    // A fresh attempt — drop stale error.
    errorId.value = null

    // Abort any in-flight synthesis and pause any current audio.
    stop()

    loadingId.value = id

    try {
      let objectUrl: string

      if (source.kind === 'blob') {
        let cached = blobObjectUrls.get(source.blob)
        if (!cached) {
          cached = trackUrl(URL.createObjectURL(source.blob))
          blobObjectUrls.set(source.blob, cached)
        }
        objectUrl = cached
      }
      else if (source.kind === 'url' || source.kind === 'lazy-url') {
        synthAbort = new AbortController()
        const signal = synthAbort.signal
        const remoteUrl = source.kind === 'url' ? source.url : await source.resolve(signal)
        // 解析期间被打断（stop() 或换了个按钮）—— 那条 URL 不再属于任何一次播放
        if (loadingId.value !== id) return

        const cached = objectUrls.get(remoteUrl)
        if (cached) {
          // 第二次起完全不碰网络。lazy-url 的 resolve 也已经命中引擎那层的 URL 缓存，
          // 所以整条路上只剩一次 Map 查询。
          synthAbort = null
          objectUrl = cached
        }
        else {
          const res = await fetch(remoteUrl, { signal })
          synthAbort = null
          if (loadingId.value !== id) return
          if (!res.ok) throw new Error(`fetch audio failed: ${res.status}`)
          objectUrl = trackUrl(URL.createObjectURL(await res.blob()))
          objectUrls.set(remoteUrl, objectUrl)
        }
      }
      else {
        const key = `tts:${id}`
        const cached = objectUrls.get(key)
        if (cached) {
          objectUrl = cached
        }
        else {
          synthAbort = new AbortController()
          const blob = await synthesize(source.text, synthAbort.signal)
          synthAbort = null
          // We may have been interrupted while awaiting (loadingId changed).
          if (loadingId.value !== id) return
          objectUrl = trackUrl(URL.createObjectURL(blob))
          objectUrls.set(key, objectUrl)
        }
      }

      const audio = new Audio(objectUrl)
      currentAudio = audio

      audio.onplaying = () => {
        if (loadingId.value === id) loadingId.value = null
        playingId.value = id
      }
      audio.onended = () => {
        if (currentAudio === audio) currentAudio = null
        if (playingId.value === id) playingId.value = null
      }
      audio.onerror = () => {
        if (currentAudio === audio) currentAudio = null
        failPlayback(id, audio.error)
      }

      try {
        await audio.play()
      }
      catch (err) {
        if (currentAudio === audio) currentAudio = null
        // pause() during a pending play() rejects with AbortError —
        // that is an intentional interrupt (from stop()), not a failure.
        if (err instanceof DOMException && err.name === 'AbortError') return
        // Most often: autoplay policy blocked the call.
        failPlayback(id, err)
      }
    }
    catch (err) {
      // Synthesis path errors. AbortError fires when stop() was called
      // mid-synthesis — that is a normal interrupt, not a failure.
      synthAbort = null
      if (err instanceof Error && err.name === 'AbortError') {
        // loadingId was already cleared synchronously by stop(); if a newer
        // play() has since set it again, that value belongs to that call,
        // not this aborted one. Do nothing here.
        return
      }
      failPlayback(id, err)
    }
  }

  function stop(): void {
    if (synthAbort) {
      synthAbort.abort()
      synthAbort = null
    }
    clearCurrentAudio()
    loadingId.value = null
    playingId.value = null
    // Note: errorId is intentionally left alone here. It is only
    // cleared at the start of a new play() attempt.
  }

  onUnmounted(() => {
    stop()
    for (const url of createdUrls) {
      try { URL.revokeObjectURL(url) } catch { /* ignore */ }
    }
    createdUrls.length = 0
    objectUrls.clear()
  })

  return {
    playingId,
    loadingId,
    errorId,
    play,
    stop,
  }
}
