import { ref, computed, watch, onUnmounted, toValue, type MaybeRefOrGetter } from 'vue'
import { useAudioRecorder } from './useAudioRecorder'
import { useAudioPlayer } from './useAudioPlayer'
import {
  createArticleSession,
  getArticleReport,
  completeArticleSession,
  getArticleDemoAudio,
  abandonArticleSession,
} from '../services/articleReading'
import { friendlyArticleError, isArticleSessionGone } from '../services/articleErrors'
import { openArticleParagraphStream, type ArticleParagraphStream } from '../services/articleReadingStream'
import { isFinalReportReady, pollDelayMs } from '../preview/articleReadingModel'
import type { ArticleReadingReport } from '@/types/articleReading'

export type ArticlePreviewStage = 'ready' | 'reading' | 'report-loading' | 'report'
/**
 * 三态，**没有原型的 `done`**（enspeak `ArticleReadingView.tsx` 有第四态「完成 ✓」）。
 *
 * 原型的 `done` 是 mock 的产物：它录完当场出分，所以能停在「完成」上让学生看一眼。
 * 真实管线是异步的（决策 A，spec §9）—— ack 一到 `advanceAfterAck` 就把 recordingState
 * 打回 'idle' 并跳去下一段，分数稍后由轮询回填。`done` 因此没有任何可以停留的时刻。
 * 别照着原型把它加回来：加回来也永远不会被赋值。
 */
export type ArticleRecordingState = 'idle' | 'recording' | 'processing'

/** 单段录音硬上限（spec §5.1）；到点只停当前段，不结束整篇。 */
const MAX_PARAGRAPH_SECONDS = 180

/**
 * 轮询**连续失败**几次后放弃（对齐话题讨论有上限的做法，`useDialogueEngine.ts:269`）。
 *
 * 计的是连续失败而不是总轮询次数：段落还在 `generating` 时的健康轮询走的是同一个
 * `pollAttempt`，拿总次数封顶会把正常等分数的学生一起掐掉。收到任何一次成功响应就归零 ——
 * 响应本身就是后端还活着的证据。
 *
 * 退避封顶 8s，所以 8 次连续失败 ≈ 0+1+2+4+8+8+8 ≈ 31 秒后停手。停手不是无声的：
 * error 横幅留在原地，report-loading 门另有手动重试按钮，学生有路可走。
 */
const MAX_POLL_FAILURES = 8

export interface ArticleReadingEngineOptions {
  configId: MaybeRefOrGetter<string>
  /** null = 编辑态老师预览（spec §4.5）。engine 只负责原样转发，判定在 ArticleReadingPreview。 */
  userId: MaybeRefOrGetter<string | null>
  durationMinutes: MaybeRefOrGetter<number>
  showReport: MaybeRefOrGetter<boolean>
  onSessionStarted?: (sessionId: string) => void
  onSessionCompleted?: (sessionId: string) => void
  /** 「重新开始」成功作废旧 session（spec §4.2）。老师端据此把「已完成」刷回「未开始」。 */
  onSessionAbandoned?: (sessionId: string) => void
}

export interface ArticleReadingEngineDeps {
  createSession: typeof createArticleSession
  getReport: typeof getArticleReport
  completeSession: typeof completeArticleSession
  getDemoAudio: typeof getArticleDemoAudio
  abandonSession: typeof abandonArticleSession
  createStream: typeof openArticleParagraphStream
  recorder: ReturnType<typeof useAudioRecorder>
  player: ReturnType<typeof useAudioPlayer>
  document: Pick<Document, 'hidden' | 'addEventListener' | 'removeEventListener'>
  setTimeout: typeof window.setTimeout
  clearTimeout: typeof window.clearTimeout
  setInterval: typeof window.setInterval
  clearInterval: typeof window.clearInterval
  /** 墙上时钟。计时器改按时间戳算之后（spec §9.2.3），测试必须能控制「现在几点」。 */
  now: () => number
}

export function useArticleReadingEngine(
  options: ArticleReadingEngineOptions,
  overrides: Partial<ArticleReadingEngineDeps> = {},
) {
  const deps: ArticleReadingEngineDeps = {
    createSession: createArticleSession,
    getReport: getArticleReport,
    completeSession: completeArticleSession,
    getDemoAudio: getArticleDemoAudio,
    abandonSession: abandonArticleSession,
    createStream: openArticleParagraphStream,
    recorder: overrides.recorder ?? useAudioRecorder(),
    player: overrides.player ?? useAudioPlayer(),
    document: globalThis.document,
    setTimeout: ((...args: Parameters<typeof setTimeout>) => setTimeout(...args)) as typeof window.setTimeout,
    clearTimeout: ((id?: number) => clearTimeout(id)) as typeof window.clearTimeout,
    setInterval: ((...args: Parameters<typeof setInterval>) => setInterval(...args)) as typeof window.setInterval,
    clearInterval: ((id?: number) => clearInterval(id)) as typeof window.clearInterval,
    now: () => Date.now(),
    ...overrides,
  }
  const recorder = deps.recorder
  const player = deps.player

  const stage = ref<ArticlePreviewStage>('ready')
  /** `POST /session` 在途。视图据此置灰「开始朗读」；闩本身在 startSession 里，按钮只是它的显示。 */
  const starting = ref(false)
  const report = ref<ArticleReadingReport | null>(null)
  const currentParagraphIdx = ref(0)
  const recordingState = ref<ArticleRecordingState>('idle')
  /** 已用练习秒数：仅计时模式下计数，非计时模式恒为 null（没有计时器）。到上限后冻结，不再累加。 */
  const elapsedPracticeSeconds = ref<number | null>(null)
  const localAudioByParagraph = ref(new Map<number, Blob>())
  const error = ref<string | null>(null)
  /**
   * 后端说这一轮已经作废（409，spec §4.2）。重试必然再失败，视图应改提示「请重新开始」。
   * 与 error 分开：error 是给人看的文案，这面旗是给视图分支用的。
   */
  const sessionInvalid = ref(false)

  const recordedParagraphs = new Set<number>()
  const demoUrlCache = new Map<string, string>()

  let activeStream: ArticleParagraphStream | null = null
  let startingRecording = false
  let finishingRecording = false
  let pollTimer: number | null = null
  let pollAttempt = 0
  /** 连续失败次数（成功即归零）。到 MAX_POLL_FAILURES 就停手，不再无限敲后端。 */
  let pollFailures = 0
  /**
   * 当前那面横幅是不是轮询自己竖起来的。
   *
   * 只有它该被下一次成功轮询收走 —— 麦克风、录音被拒、示范音频那些错误不能碰。
   * 尤其是录音被拒（`Session has ended`）：那条 catch 随后就 `schedulePoll(0)`，
   * 若在 acceptReport 里无差别清 error，学生会在唯读锁合上的同一瞬间失去
   * 「为什么我录不了」的唯一解释。
   */
  let pollErrorShown = false
  let pollInFlight = false
  let practiceTimer: number | null = null
  let reportAbort: AbortController | null = null
  let disposed = false
  let waitingForVisible = false

  let completionPromise: Promise<void> | null = null
  let completionNotified = false
  let completionSucceeded = false
  let hasFetchedAfterComplete = false

  /**
   * 已录完的段落 = 后端已知有结果（generating/completed/failed），或本地刚 ack 过。
   * 本地集合让最后一段 ack 后按钮立刻可用，不必等下一轮 GET。绝不本地伪造分数。
   */
  const canViewReport = computed(() => {
    const current = report.value
    if (!current || current.paragraphs.length === 0) return false
    return current.paragraphs.every(
      paragraph => paragraph.status !== 'pending' || recordedParagraphs.has(paragraph.idx),
    )
  })

  /** 这一轮是否已结束。showReport 关闭时，视图据此把练习页转为只读（spec §9.2.1）。 */
  const sessionEnded = computed(() => report.value?.status === 'completed')

  /**
   * 整篇总评已经开始生成（`overallStatus` 非 null = generating / completed / failed）。
   *
   * 后端 2026-07-27 起在这之后**拒绝重录**（409 `Report already generated`）：放行不是多烧一次
   * LLM，而是作废一份学生/老师可能已经看到的报告。视图据此提前锁掉录音，否则那颗按钮按下去
   * 必然报错。判据是 `overallStatus` 而不是 `sessionEnded` —— 静默预热把 status 翻成
   * completed 的那一刻总评还没开始，那时重录仍然允许。
   */
  const overallStarted = computed(() => (report.value?.overallStatus ?? null) !== null)

  // ---------------- session ----------------

  async function startSession() {
    /**
     * 双击「开始朗读」= 两个 `POST /session`，而后端的 get-or-create 既没有锁也没有唯一约束
     * （`article_service.py` `get_or_create_session`）：两个并发请求会双双查不到既有 session，
     * 然后各插一行。编辑态老师预览（`userId=null`）更是**必然**两行 —— `_latest_session`
     * 对空 user 早退返回 None 是设计如此（spec §4.5）。
     *
     * 多出来的那一行不是无害的垃圾：它一路算进 §4.3 B 的「N 名学生正在练习中」直到
     * `expires_at` 把它扫掉，老师明明只有一个学生在读却被问「2 名学生正在练习中」；
     * `onSessionStarted` 也会跑两次，宿主的作业表因此多一笔（§9.2.4）。
     *
     * 闩放在这里而不是只置灰按钮：按钮只挡得住鼠标，挡不住程序化调用（承 §3.2.1
     * `guardBeforeApply` 早退同一条理由）。对齐话题讨论的 `sessionCreating`
     * （`TopicDiscussionPreview.vue:33`）。
     */
    if (starting.value) return
    starting.value = true
    error.value = null
    sessionInvalid.value = false
    // 每一轮都从干净的完成态起步。reset() 也清这四个，但 startSession 不该依赖
    // 「上一次一定 reset 过、且没有在途的 POST 在 reset 之后把它们设回来」
    completionPromise = null
    completionNotified = false
    completionSucceeded = false
    hasFetchedAfterComplete = false
    try {
      const next = await deps.createSession(toValue(options.configId), toValue(options.userId))
      report.value = next
      stage.value = 'reading'
      currentParagraphIdx.value = firstPendingIdx(next)
      options.onSessionStarted?.(next.sessionId)
      startPracticeTimer()
      if (next.paragraphs.some(paragraph => paragraph.status === 'generating')) schedulePoll(0)
    } catch (cause) {
      error.value = friendlyArticleError(cause)
    } finally {
      // 只在这里放闩。reset() **故意不清它** —— 请求还在飞的时候把闩打开，
      // 第二次点击照样能发出第二个 POST，等于没修。
      starting.value = false
    }
  }

  function firstPendingIdx(current: ArticleReadingReport): number {
    const pending = current.paragraphs.find(paragraph => paragraph.status === 'pending')
    return pending ? pending.idx : 0
  }

  /**
   * 刷新页面 resume：用后端最新 session 的 report 直接水合引擎，跳过 ready 起始页。
   * reading → 回到朗读视图；completed → 回到结果页（overall 未就绪则走一次性 loading 门轮询）。
   * 不触发 onSessionStarted（那是真正开始时记作业用）；presence 广播由调用方负责。
   */
  function resumeFromReport(next: ArticleReadingReport) {
    error.value = null
    sessionInvalid.value = false
    report.value = next

    if (next.status === 'completed') {
      // 已完成：complete 不必再 POST；标记成已完成态，防止 preheat 重发或重复通知
      completionPromise = Promise.resolve()
      completionSucceeded = true
      completionNotified = true
      hasFetchedAfterComplete = false
      if (!toValue(options.showReport)) {
        // 关闭「展示学习报告」：完成也不进结果页，留在练习页（只读锁由 sessionEnded 触发，spec §9.2.1）
        stage.value = 'reading'
        currentParagraphIdx.value = firstPendingIdx(next)
        return
      }
      if (isFinalReportReady(next)) {
        stage.value = 'report'
      } else {
        // 段落已落定但整篇总评仍 generating → 走一次性 loading 门 + 退避轮询
        stage.value = 'report-loading'
        schedulePoll(0)
      }
      return
    }

    // reading：回到朗读视图。计时器改按时间戳算之后，resume 重启是安全的 ——
    // 它从 expiresAt 反推起点，算出的是真实已用时长，不会「刷新即重置」。
    stage.value = 'reading'
    currentParagraphIdx.value = firstPendingIdx(next)
    startPracticeTimer()
    if (next.paragraphs.some(paragraph => paragraph.status === 'generating')) schedulePoll(0)
  }

  // ---------------- recording ----------------

  async function startRecording() {
    /**
     * `recordingState` 要等 getUserMedia + addModule 落地才翻成 'recording'，那期间
     * 录音按钮是亮的、idle 那一栏也还在 —— 少了 startingRecording 这道闩，连点两下
     * 就会**建起两套采集图**：两个 worklet 的 onmessage 往同一个 pcmChunks 里塞，
     * 采样数翻倍且两路交错，本地回放听起来就是「慢一半 + 刺啦声」；`onChunk` 同样被
     * 两路调用，上行 WS 与后端存的 WAV 一并被污染。第二次调用还会覆盖 activeStream，
     * 把第一条 WS 连同它的 attempt 一起漏在半空。
     */
    if (!report.value || recordingState.value === 'recording' || startingRecording) return
    startingRecording = true
    try {
      await beginRecording()
    }
    finally {
      startingRecording = false
    }
  }

  async function beginRecording() {
    if (!report.value) return
    error.value = null
    // 录音器先于 socket 就绪吐 chunk 是常态，先缓在本地，建流后补推，开头不能丢
    const beforeStream: ArrayBuffer[] = []
    recorder.onChunk.value = chunk => {
      if (activeStream) activeStream.pushChunk(chunk)
      else beforeStream.push(chunk)
    }

    try {
      await recorder.startRecording()
    } catch (cause) {
      recorder.onChunk.value = null
      error.value = friendlyArticleError(cause)
      return
    }

    activeStream = deps.createStream({
      sessionId: report.value.sessionId,
      paragraphIdx: currentParagraphIdx.value,
      sampleRate: recorder.sampleRate.value,
    })
    for (const chunk of beforeStream) activeStream.pushChunk(chunk)
    beforeStream.length = 0
    recordingState.value = 'recording'
  }

  async function finishRecording() {
    if (!activeStream || finishingRecording) return
    finishingRecording = true
    recordingState.value = 'processing'
    const paragraphIdx = currentParagraphIdx.value
    const stream = activeStream
    const sessionId = report.value?.sessionId
    /**
     * 两个 await 期间「重新开始」都可能跑过 reset()，把引擎带到另一轮。
     * 之后任何回写都是污染：blob 会落进刚清空的 Map（新一轮那段带着「已录制」徽章
     * 和一个播放上一次录音的回放按钮），activeStream/recordingState 则会踩掉新一轮。
     */
    const stillCurrent = () => report.value?.sessionId === sessionId

    try {
      const wav = await recorder.stopRecording()
      if (!stillCurrent()) {
        stream.abort('Article session reset while finishing')
        return
      }
      activeStream = null
      await stream.stop()
      if (!stillCurrent()) return
      localAudioByParagraph.value.set(paragraphIdx, wav)
      recordedParagraphs.add(paragraphIdx)
      advanceAfterAck(paragraphIdx)
      schedulePoll(0)
    }
    catch (cause) {
      stream.abort('Article recording attempt failed')
      if (!stillCurrent()) return
      activeStream = null
      if (isArticleSessionGone(cause)) sessionInvalid.value = true
      error.value = friendlyArticleError(cause)
      recordingState.value = 'idle'
      // 失败可能是「后端说这一轮已到期」（409 Session has ended，§4.4）。补一次查询，
      // 让 status: completed 经 acceptReport 落地 —— 否则学生要等到下一次主动动作
      // 才会发现自己已经结束了。
      schedulePoll(0)
    }
    finally {
      if (stillCurrent()) recorder.onChunk.value = null
      finishingRecording = false
    }
  }

  /**
   * 中途放弃本次录音：abort 掉流（后端 attempt 已 +1，旧分数保留、无任务写入），
   * 回到 idle。不发 stop，因此不会产生评分任务。
   */
  async function cancelRecording() {
    if (!activeStream || finishingRecording) return
    const stream = activeStream
    activeStream = null
    stream.abort('Article recording cancelled by user')
    try {
      await recorder.stopRecording()
    } catch {
      // 录音器已经停了或从未真正启动，忽略
    }
    recorder.onChunk.value = null
    recordingState.value = 'idle'
    error.value = null
  }

  /** ack 到手即前进，不等分数（异步 UX 决策 A）。最后一段则留在原地。 */
  function advanceAfterAck(paragraphIdx: number) {
    recordingState.value = 'idle'
    const current = report.value
    if (!current) return
    const next = current.paragraphs.find(
      paragraph => paragraph.idx > paragraphIdx && paragraph.status === 'pending'
        && !recordedParagraphs.has(paragraph.idx),
    )
    if (next) currentParagraphIdx.value = next.idx
  }

  function rerecordParagraph(idx: number) {
    // 后端重录会重开 session 并作废整篇总评，本地完成态必须一并清掉
    recordedParagraphs.delete(idx)
    localAudioByParagraph.value.delete(idx)
    completionPromise = null
    completionNotified = false
    completionSucceeded = false
    hasFetchedAfterComplete = false
    currentParagraphIdx.value = idx
    stage.value = 'reading'
    error.value = null
  }

  function selectParagraph(idx: number) {
    if (recordingState.value === 'recording') return
    currentParagraphIdx.value = idx
  }

  // ---------------- timers ----------------

  // 单段 180s 安全阀：只停当前段，不结束整篇
  watch(() => recorder.recordingDuration.value, duration => {
    if (duration >= MAX_PARAGRAPH_SECONDS && recordingState.value === 'recording') {
      void finishRecording()
    }
  })

  /** report 上的截止时刻，毫秒；无截止 / 解析不出来 → null。 */
  function deadlineMs(current: ArticleReadingReport | null): number | null {
    if (!current?.expiresAt) return null
    const parsed = Date.parse(current.expiresAt)
    return Number.isNaN(parsed) ? null : parsed
  }

  /**
   * 练习计时器：**纯展示**（spec §9.2.3）。
   *
   * 值从 `expiresAt` 与配置时长反推起点、按墙上时钟算，不再逐 tick 累加 —— 后台标签页
   * 的 setInterval 会被浏览器节流（可能一分钟才跑一次），累加法切回来时显示的数字是错的。
   * 同样因为这个算法，刷新页面 resume 时可以直接重启计时器：它会自己算出真实已用时长。
   *
   * 到点**什么都不做**，只停自己 + 发一次 GET /report。到期由后端判定（§4.4）；
   * 本地时钟只是触发器，不是判据：算早了后端说还没到，算晚了后端早已标好，两种都不会错。
   */
  function startPracticeTimer() {
    stopPracticeTimer()
    const expiresAtMs = deadlineMs(report.value)
    if (expiresAtMs === null) {
      elapsedPracticeSeconds.value = null
      return
    }
    const capSeconds = Math.max(0, Math.round(toValue(options.durationMinutes) * 60))
    if (capSeconds <= 0) {
      elapsedPracticeSeconds.value = null
      return
    }
    const startedAtMs = expiresAtMs - capSeconds * 1000
    const tick = () => {
      const elapsed = Math.floor((deps.now() - startedAtMs) / 1000)
      elapsedPracticeSeconds.value = Math.min(capSeconds, Math.max(0, elapsed))
      if (elapsed < capSeconds) return
      stopPracticeTimer()
      // 到点问一次。schedulePoll 复用既有的后台标签页挂起与去重逻辑；
      // 没有 generating 段时 acceptReport 会 stopPolling()，所以确实只有这一次。
      schedulePoll(0)
    }
    tick()
    practiceTimer = deps.setInterval(tick, 1000)
  }

  /** 清掉练习页上的错误横幅。重试无从谈起的那些错（麦克风、示范音频、轮询）只能这样收走。 */
  function dismissError() {
    error.value = null
    pollErrorShown = false
  }

  function stopPracticeTimer() {
    if (practiceTimer !== null) {
      deps.clearInterval(practiceTimer)
      practiceTimer = null
    }
  }

  // ---------------- completion ----------------

  function completeOnce(): Promise<void> {
    if (!report.value) return Promise.reject(new Error('Article session is not ready'))
    if (!completionPromise) {
      const sessionId = report.value.sessionId
      completionPromise = deps.completeSession(sessionId).then(() => {
        // POST 在途时学生可能已经「重新开始」。reset() 清过这些标记，这里绝不能再设回去 ——
        // 否则下一轮开局就带着 completionNotified=true，那一轮真正完成时 onSessionCompleted
        // 不再触发，老师端整轮都收不到刷新（复合放大「重新开始不广播」那条）。
        if (report.value?.sessionId !== sessionId) return
        completionSucceeded = true
        hasFetchedAfterComplete = false
        if (!completionNotified) {
          completionNotified = true
          options.onSessionCompleted?.(sessionId)
        }
      })
    }
    return completionPromise
  }

  function allParagraphsRecorded(current: ArticleReadingReport): boolean {
    return current.paragraphs.every(paragraph =>
      paragraph.status === 'generating'
        || paragraph.status === 'completed'
        || paragraph.status === 'failed',
    )
  }

  /** 全段评完就静默 complete，让老师端列表提前刷新；不改 stage。 */
  async function preheatIfEligible(current: ArticleReadingReport) {
    if (!allParagraphsRecorded(current)) return
    if (current.paragraphs.some(paragraph => paragraph.status === 'generating')) return
    try {
      await completeOnce()
    } catch {
      completionPromise = null
    }
  }

  async function completeAndShowReport() {
    if (recordingState.value === 'recording') await finishRecording()
    stopPracticeTimer()
    stage.value = 'report-loading'
    error.value = null
    try {
      await completeOnce()
      schedulePoll(0)
    }
    catch (cause) {
      completionPromise = null
      if (isArticleSessionGone(cause)) sessionInvalid.value = true
      error.value = friendlyArticleError(cause)
    }
  }

  /**
   * 「重新开始」（spec §4.2）：把当前 session 标作废，回起始页。
   * 不在这里建新 session —— 学生点「开始朗读」时由 startSession 的 get-or-create 建，
   * 那样 created_at 才等于真正的开始时刻。
   * @returns 成功与否；失败时不重置，调用方负责提示。
   */
  async function restartSession(): Promise<boolean> {
    const sessionId = report.value?.sessionId
    if (!sessionId) return false
    try {
      await deps.abandonSession(sessionId)
    }
    catch (cause) {
      console.error('[article-reading] abandon failed:', cause)
      return false
    }
    reset()
    // 库里已经是「未开始」了（§4.2 各处查询排除 abandoned），但老师端只在收到学生广播时
    // 才重取；不发这一条，他会一直看着一个不再作数的「已完成 + 分数」，点进去还是作废那轮的报告。
    options.onSessionAbandoned?.(sessionId)
    return true
  }

  async function acceptReport(next: ArticleReadingReport) {
    const wasReading = report.value?.status === 'reading'
    report.value = next

    /**
     * 后端单方面把这一轮结束了（到期，spec §4.4）。这是学生结束的**唯一信号**，
     * 而它可能来自任何一次响应 —— 到点那次查询、恢复探针、或学生自己的动作触发的轮询，
     * 所以反应必须写在这个统一入口，不能绑在某一次调用上。
     *
     * 下面那句 `if (!completionSucceeded) return` 原本假定「只有我能结束 session」；
     * 判定权搬到后端后这个前提不再成立，必须先在这里补齐本地的完成态，否则这次响应
     * 会被那句早退直接吞掉。
     *
     * 放在 preheatIfEligible **之前**：preheat 走的是 completeOnce()，而这里已经把
     * completionPromise 填好，于是它自然成为 no-op。反过来先 preheat 的话，它会先
     * 把 completionSucceeded 置真、让下面的条件失效，还白发一次没有意义的 POST，
     * 并因为「POST 之后还没 GET 过」再多轮询一轮。
     */
    if (wasReading && next.status === 'completed' && !completionSucceeded) {
      stopPracticeTimer()
      completionPromise = Promise.resolve()
      completionSucceeded = true
      // 这个响应本身就是「结束之后拿到的」—— 后端在这次查询里完成的收口
      hasFetchedAfterComplete = true
      if (!completionNotified) {
        completionNotified = true
        options.onSessionCompleted?.(next.sessionId)
      }
      // showReport 关闭时不进 loading 门：留在练习页，由 sessionEnded 合上只读锁（§9.2.1）
      if (toValue(options.showReport)) stage.value = 'report-loading'
    }

    await preheatIfEligible(next)

    if (!completionSucceeded) {
      if (!next.paragraphs.some(paragraph => paragraph.status === 'generating')) stopPolling()
      return
    }

    // POST 之前抓到的 report 不算数：必须至少有一次 POST 之后发起的 GET
    if (!hasFetchedAfterComplete) {
      schedulePoll(0)
      return
    }

    if (isFinalReportReady(next)) {
      if (stage.value === 'report-loading') stage.value = 'report'
      stopPolling()
    } else {
      // 段落已全部落定，但整篇 overallStatus 仍是 'generating'（complete 触发的后台总评
      // LLM 未完成）。此时没有 generating 段，pollOnce 的兜底重排不会触发，必须在这里继续
      // 退避轮询，否则 report-loading 门会永久卡住等一个再也不来的 GET。
      schedulePoll(pollAttempt + 1)
    }
  }

  // ---------------- polling ----------------

  function schedulePoll(attempt: number) {
    if (disposed) return
    // attempt === 0 = 重新起一轮（录完一段、点重试、到点查一次、resume）。
    // 这是用户动作或新事件带来的，给它一份新的失败预算 —— 否则上一次敲到上限之后，
    // 之后每一条新链都会在第一次失败时立刻再次停手。
    if (attempt === 0) pollFailures = 0
    pollAttempt = attempt
    if (pollTimer !== null) deps.clearTimeout(pollTimer)
    pollTimer = deps.setTimeout(() => {
      pollTimer = null
      void pollOnce()
    }, attempt === 0 ? 0 : pollDelayMs(attempt - 1))
  }

  function stopPolling() {
    if (pollTimer !== null) {
      deps.clearTimeout(pollTimer)
      pollTimer = null
    }
    pollAttempt = 0
    // 不清 pollFailures：停手那一刻它正好等于上限，清了就没意义了。
    // 由重新起一轮轮询的入口负责归零 —— 见 schedulePoll(0)。
  }

  function onVisible() {
    deps.document.removeEventListener('visibilitychange', onVisible)
    waitingForVisible = false
    // 后台期间不计入重试次数，回到前台立即补一次
    schedulePoll(0)
  }

  async function pollOnce() {
    if (disposed || pollInFlight || !report.value) return

    // 后台标签页不发 GET，挂起等 visibilitychange，且不推进退避
    if (deps.document.hidden) {
      if (!waitingForVisible) {
        waitingForVisible = true
        deps.document.addEventListener('visibilitychange', onVisible)
      }
      return
    }

    pollInFlight = true
    const requestStartedAfterComplete = completionSucceeded
    reportAbort = new AbortController()
    try {
      const next = await deps.getReport(report.value.sessionId, reportAbort.signal)
      if (disposed) return
      // 拿到响应 = 后端还活着，连续失败计数归零
      pollFailures = 0
      // 上一次轮询失败留下的横幅已经不成立了，收走它（只收自己竖的那面）
      if (pollErrorShown) {
        error.value = null
        pollErrorShown = false
      }
      if (requestStartedAfterComplete) hasFetchedAfterComplete = true
      const wasPolling = pollTimer
      await acceptReport(next)
      // acceptReport 未自行安排下一轮，且仍有段落在生成 → 退避重试
      if (pollTimer === wasPolling && next.paragraphs.some(p => p.status === 'generating')) {
        schedulePoll(pollAttempt + 1)
      }
    } catch (cause) {
      if (disposed || (cause instanceof Error && cause.name === 'AbortError')) return
      // 瞬时失败不退出 loading 门，退避后再试
      error.value = friendlyArticleError(cause)
      pollErrorShown = true
      pollFailures += 1
      if (pollFailures >= MAX_POLL_FAILURES) {
        // 后端连续不应答，别再每 8 秒敲一次敲到学生关页面。横幅留在原地，
        // report-loading 门那个手动重试按钮是学生的出口。
        stopPolling()
        return
      }
      schedulePoll(pollAttempt + 1)
    } finally {
      reportAbort = null
      pollInFlight = false
    }
  }

  // ---------------- audio ----------------

  /**
   * 全篇（示范 + 我的录音）共用一个 player，所以「点别的按钮」天然会 stop 掉上一段。
   * 这里只负责另一半：**同一颗按钮再点一次 = 停**。loading 也要算进来，否则取流/
   * 合成的那 1-3 秒里按钮按不停，只会排队再放一遍（对齐话题讨论 DialogueChatView:898）。
   */
  function isBusyWith(id: string): boolean {
    return player.playingId.value === id || player.loadingId.value === id
  }

  async function playOwnAudio(idx: number) {
    const id = `own-${idx}`
    if (isBusyWith(id)) {
      player.stop()
      return
    }
    const localBlob = localAudioByParagraph.value.get(idx)
    if (localBlob) {
      await player.play(id, { kind: 'blob', blob: localBlob })
      return
    }
    const url = report.value?.paragraphs.find(paragraph => paragraph.idx === idx)?.audioUrl
    if (!url) return
    await player.play(id, { kind: 'url', url })
  }

  async function playDemoAudio(idx: number) {
    const id = `demo-${idx}`
    if (isBusyWith(id)) {
      player.stop()
      return
    }
    const sessionId = report.value?.sessionId
    if (!sessionId) return
    const cacheKey = `${sessionId}:${idx}`
    // 取 URL 交给 player（lazy-url），不在这儿先 await：缓存 miss 时后端同步合成要
    // 1-3s，在外面等的话这段时间里上一段录音还在响、这颗按钮也不转圈 —— 学生看到的
    // 就是「点了没反应，旧的还在放」。进了 play() 才有 stop() 和 loadingId。
    await player.play(id, {
      kind: 'lazy-url',
      resolve: async () => {
        const cached = demoUrlCache.get(cacheKey)
        if (cached) return cached
        try {
          const res = await deps.getDemoAudio(sessionId, idx)
          demoUrlCache.set(cacheKey, res.url)
          return res.url
        } catch (cause) {
          // 横幅仍由引擎竖：player 只有 errorId，卡片上没有它的出口
          error.value = friendlyArticleError(cause)
          throw cause
        }
      },
    })
  }

  function stopAudio() {
    player.stop()
  }

  // ---------------- lifecycle ----------------

  function reset() {
    stopPolling()
    pollFailures = 0
    pollErrorShown = false
    stopPracticeTimer()
    reportAbort?.abort()
    reportAbort = null
    activeStream?.abort('Article session reset')
    activeStream = null
    player.stop()
    recorder.onChunk.value = null
    // 录音器必须真的拆掉，不能只摘 onChunk：mediaStream 的 track、AudioContext、
    // AudioWorkletNode 和时长 interval 都还活着。留着的后果不是「泄漏一点内存」——
    // 下一次 startRecording 会挂上第二个时长 interval（180s 单段安全阀在 ~90 真实秒就开火），
    // 而孤儿 worklet 的 onmessage 闭包着同一个 chunk 缓冲与 onChunk ref，会继续把音频
    // 灌进**下一段**录音，上传的 WAV 与 WS 流里都是两套采集图交织的声音。
    // 放在 reset() 而不是 restartSession()：configId 切换与老师端「应用配置」同样只走
    // reset()，三条路一次修完；completeAndShowReport / cancelRecording 本就先过录音器。
    recorder.cleanup()
    recordedParagraphs.clear()
    localAudioByParagraph.value.clear()
    demoUrlCache.clear()
    completionPromise = null
    completionNotified = false
    completionSucceeded = false
    hasFetchedAfterComplete = false
    finishingRecording = false
    stage.value = 'ready'
    report.value = null
    currentParagraphIdx.value = 0
    recordingState.value = 'idle'
    elapsedPracticeSeconds.value = null
    error.value = null
    sessionInvalid.value = false
    if (waitingForVisible) {
      deps.document.removeEventListener('visibilitychange', onVisible)
      waitingForVisible = false
    }
  }

  function dispose() {
    disposed = true
    reset()
  }

  onUnmounted(() => dispose())

  return {
    stage,
    starting,
    report,
    currentParagraphIdx,
    recordingState,
    recordingDuration: recorder.recordingDuration,
    elapsedPracticeSeconds,
    sessionEnded,
    overallStarted,
    canViewReport,
    localAudioByParagraph,
    error,
    sessionInvalid,
    startSession,
    resumeFromReport,
    startRecording,
    finishRecording,
    cancelRecording,
    rerecordParagraph,
    selectParagraph,
    completeAndShowReport,
    dismissError,
    restartSession,
    playOwnAudio,
    playDemoAudio,
    stopAudio,
    playingId: player.playingId,
    loadingId: player.loadingId,
    reset,
    dispose,
  }
}
