import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import ts from 'typescript'

const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/composables/useArticleReadingEngine.ts', import.meta.url)
let source = await readFile(sourceUrl, 'utf8')

// Vue 运行时替换为最小 shim；watch 只需支持 getter + 立即回调语义
source = source.replace(
  "import { ref, computed, watch, onUnmounted, toValue, type MaybeRefOrGetter } from 'vue'",
  `
const __watchers = []
const ref = value => {
  const r = { _v: value, get value() { return this._v }, set value(next) { this._v = next; __runWatchers() } }
  return r
}
const computed = getter => ({ get value() { return getter() } })
const watch = (getter, cb) => { __watchers.push({ getter, cb, last: getter() }) }
const onUnmounted = () => {}
const toValue = v => (typeof v === 'function' ? v() : (v && typeof v === 'object' && 'value' in v ? v.value : v))
globalThis.__runWatchers = () => {
  for (const w of __watchers) {
    const next = w.getter()
    if (next !== w.last) { const prev = w.last; w.last = next; w.cb(next, prev) }
  }
}
`,
)

// 运行时依赖全部替换为注入桩；测试永远传完整 overrides，这些默认值不会被执行
source = source
  .replace("import { useAudioRecorder } from './useAudioRecorder'", 'const useAudioRecorder = () => globalThis.__deps.recorder')
  .replace("import { useAudioPlayer } from './useAudioPlayer'", 'const useAudioPlayer = () => globalThis.__deps.player')
  .replace(
    /import \{[\s\S]*?\} from '\.\.\/services\/articleReading'/,
    `
const createArticleSession = (...a) => globalThis.__deps.createSession(...a)
const getArticleReport = (...a) => globalThis.__deps.getReport(...a)
const completeArticleSession = (...a) => globalThis.__deps.completeSession(...a)
const getArticleDemoAudio = (...a) => globalThis.__deps.getDemoAudio(...a)
const abandonArticleSession = (...a) => globalThis.__deps.abandonSession(...a)
`,
  )
  .replace(
    /import \{[\s\S]*?\} from '\.\.\/services\/articleErrors'/,
    `
const friendlyArticleError = cause => {
  const raw = cause instanceof Error ? cause.message : String(cause ?? '')
  return raw === 'Session not found' ? '__localized_session_not_found__' : '__localized_generic__'
}
const isArticleSessionGone = cause => cause?.status === 409
`,
  )
  .replace(
    "import { openArticleParagraphStream, type ArticleParagraphStream } from '../services/articleReadingStream'",
    'const openArticleParagraphStream = (...a) => globalThis.__deps.createStream(...a)',
  )
  .replace(
    "import { isFinalReportReady, pollDelayMs } from '../preview/articleReadingModel'",
    `
const isFinalReportReady = r => !r.paragraphs.some(p => p.status === 'generating')
  && (r.overallStatus === 'completed' || r.overallStatus === 'failed' || r.overallStatus === null)
const pollDelayMs = attempt => Math.min(8000, 1000 * 2 ** Math.max(0, attempt))
`,
  )

const compiled = ts.transpileModule(source, {
  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText
const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)

// ---------------- fakes ----------------

function makeRecorder(sampleRate = 48000) {
  const rec = {
    recordingDuration: { _v: 0, get value() { return this._v }, set value(v) { this._v = v; globalThis.__runWatchers() } },
    sampleRate: { value: sampleRate },
    onChunk: { value: null },
    started: 0,
    cleaned: 0,
    async startRecording() { this.started += 1 },
    async stopRecording() { return { wav: true, size: 1 } },
    // 真实实现在这一步停 mediaStream track、关 AudioContext、断开 worklet、清时长 interval。
    // 不拆的后果不是泄漏一点内存：孤儿 worklet 会继续把音频灌进下一段录音。
    cleanup() { this.cleaned += 1 },
  }
  return rec
}

function makePlayer() {
  return {
    playingId: { value: null },
    loadingId: { value: null },
    errorId: { value: null },
    plays: [],
    // lazy-url 的 URL 解析发生在 play() 内部（与真 player 一致）：示范音频那条
    // 失败路径要经过这里，引擎的 error.value 才立得起来
    async play(id, source) {
      this.plays.push({ id, source })
      this.loadingId.value = id
      if (source.kind === 'lazy-url') {
        try { await source.resolve(new AbortController().signal) }
        catch {
          this.loadingId.value = null
          this.errorId.value = id
          return
        }
      }
      this.loadingId.value = null
      this.playingId.value = id
    },
    stop() { this.playingId.value = null; this.loadingId.value = null },
  }
}

function makeStream() {
  const stream = {
    chunks: [],
    stopped: false,
    aborted: null,
    resolveAck: null,
    pushChunk(c) { this.chunks.push(c) },
    stop() { this.stopped = true; return new Promise(res => { this.resolveAck = res }) },
    abort(reason) { this.aborted = reason },
  }
  return stream
}

/** 手控时钟：测试自己决定何时推进 timeout/interval。 */
function makeClock() {
  let nextId = 1
  let nowMs = Date.UTC(2026, 0, 1, 0, 0, 0)
  const timeouts = new Map()
  const intervals = new Map()
  return {
    timeouts,
    intervals,
    now() { return nowMs },
    /** 推进墙上时钟（秒）。不自动触发 interval —— 用 tickInterval 显式驱动。 */
    advance(seconds) { nowMs += seconds * 1000 },
    setTimeout(fn, delay) { const id = nextId++; timeouts.set(id, { fn, delay }); return id },
    clearTimeout(id) { timeouts.delete(id) },
    setInterval(fn, delay) { const id = nextId++; intervals.set(id, { fn, delay }); return id },
    clearInterval(id) { intervals.delete(id) },
    async runTimeouts() {
      const pending = [...timeouts.entries()]
      timeouts.clear()
      for (const [, t] of pending) { t.fn(); await flush() }
    },
    async tickInterval(times = 1) {
      for (let i = 0; i < times; i++) {
        for (const [, t] of [...intervals.entries()]) t.fn()
        await flush()
      }
    },
  }
}

function makeDoc(hidden = false) {
  return {
    hidden,
    listeners: {},
    addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn) },
    removeEventListener(type, fn) {
      this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn)
    },
    fire(type) { for (const fn of [...(this.listeners[type] || [])]) fn() },
  }
}

const flush = () => new Promise(resolve => setImmediate(resolve))

function paragraphs(statuses) {
  return statuses.map((status, idx) => ({
    idx, text: `p${idx}`, status, audioUrl: null, overallScore: null, dims: null, words: [],
  }))
}

function reportOf(statuses, overallStatus = null, extra = {}) {
  return {
    sessionId: 'sess-1', title: 'T', status: 'reading', totalDurationSeconds: null,
    // §4.4：绝对截止时刻。默认 null = 不限时，既有用例的行为因此不变。
    expiresAt: null,
    paragraphs: paragraphs(statuses), overall: null, overallStatus, ...extra,
  }
}

function setup({
  statuses = ['pending', 'pending'], reports = [],
  showReport = true, durationMinutes = 1, expiresAt = null, userId = 'u1', abandonSession, completeSession,
  getReport, createSession, getDemoAudio, onSessionAbandoned, doc, clock,
} = {}) {
  const recorder = makeRecorder()
  const player = makePlayer()
  const streams = []
  const calls = { complete: 0, getReport: 0, demo: 0, abandon: 0 }
  let reportQueue = [...reports]

  const deps = {
    recorder,
    player,
    document: doc ?? makeDoc(),
    setTimeout: (...a) => clock.setTimeout(...a),
    clearTimeout: id => clock.clearTimeout(id),
    setInterval: (...a) => clock.setInterval(...a),
    clearInterval: id => clock.clearInterval(id),
    now: () => clock.now(),
    async createSession(...a) {
      if (createSession) return createSession(...a)
      return reportOf(statuses, null, { expiresAt })
    },
    async getReport() {
      calls.getReport += 1
      if (getReport) return getReport()
      return reportQueue.length > 1 ? reportQueue.shift() : (reportQueue[0] ?? reportOf(statuses, null, { expiresAt }))
    },
    completeSession: completeSession ?? (async () => { calls.complete += 1 }),
    async getDemoAudio(...a) {
      calls.demo += 1
      if (getDemoAudio) return getDemoAudio(...a)
      return { url: 'https://s3/demo.mp3' }
    },
    abandonSession: abandonSession ?? (async id => { calls.abandon += 1; return { sessionId: id, status: 'abandoned' } }),
    createStream(opts) { const s = makeStream(); s.opts = opts; streams.push(s); return s },
  }
  globalThis.__deps = deps

  const completed = []
  const engine = mod.useArticleReadingEngine(
    {
      configId: 'c1', userId, durationMinutes, showReport,
      onSessionCompleted: id => completed.push(id),
      onSessionAbandoned,
    },
    deps,
  )
  return { engine, recorder, player, streams, calls, completed, deps, setReports: r => { reportQueue = [...r] } }
}

// ---- 1. stream 建立前的 PCM 不丢；handshake 用 recorder 真实 sampleRate ----
{
  const clock = makeClock()
  const { engine, recorder, streams } = setup({ clock })
  await engine.startSession()
  await clock.runTimeouts()

  const startPromise = engine.startRecording()
  const early = new Uint8Array([7, 7]).buffer
  recorder.onChunk.value?.(early)
  await startPromise

  assert.equal(streams.length, 1)
  assert.equal(streams[0].opts.sampleRate, 48000, 'handshake must use the recorder sample rate')
  assert.equal(streams[0].chunks[0], early, 'PCM produced before the stream existed must still be first')
}

// ---- 2. finishRecording：先取 WAV 再 stop；ack 后立刻前进，不等分数 ----
{
  const clock = makeClock()
  const { engine, streams } = setup({ clock })
  await engine.startSession()
  await clock.runTimeouts()
  await engine.startRecording()

  const finishing = engine.finishRecording()
  await flush()
  assert.equal(streams[0].stopped, true, 'stop must be sent after the WAV blob is taken')
  assert.equal(engine.recordingState.value, 'processing')

  streams[0].resolveAck({ type: 'ack', status: 'generating', attempt: 1, truncated: false })
  await finishing

  assert.equal(engine.currentParagraphIdx.value, 1, 'must advance on ack without waiting for scores')
  assert.ok(engine.localAudioByParagraph.value.get(0), 'own recording must be replayable immediately')
}

// ---- 3. 单段 180s 只停当前段；练习计时归零才 complete ----
{
  const clock = makeClock()
  const { engine, recorder, streams, calls } = setup({ clock })
  await engine.startSession()
  await clock.runTimeouts()
  await engine.startRecording()

  recorder.recordingDuration.value = 180
  await flush()
  streams[0].resolveAck?.({ type: 'ack', status: 'generating', attempt: 1, truncated: false })
  await flush()
  assert.equal(calls.complete, 0, '180s paragraph cap must not complete the whole practice')
}
// ---- 3a. 计时器按时间戳算：后台标签页节流也不会少算（spec §9.2.3）----
{
  const clock = makeClock()
  const expiresAt = new Date(clock.now() + 600_000).toISOString()  // 10 分钟后
  const { engine } = setup({ clock, expiresAt, durationMinutes: 10 })
  await engine.startSession()
  await clock.runTimeouts()
  assert.equal(engine.elapsedPracticeSeconds.value, 0, 'count-up timer starts at zero')

  // 墙上时钟走了 300 秒，但 interval 只被浏览器放行了一次
  clock.advance(300)
  await clock.tickInterval(1)
  assert.equal(engine.elapsedPracticeSeconds.value, 300,
    'tick accumulation would say 1; only a timestamp reading says 300')
}

// ---- 3b. 到点：冻结在上限，且绝不发 complete ----
{
  const clock = makeClock()
  const expiresAt = new Date(clock.now() + 600_000).toISOString()
  const { engine, calls } = setup({ clock, expiresAt, durationMinutes: 10 })
  await engine.startSession()
  await clock.runTimeouts()

  clock.advance(700)               // 越过截止时刻
  await clock.tickInterval(1)
  assert.equal(engine.elapsedPracticeSeconds.value, 600, 'must freeze at the cap, not overshoot')
  assert.equal(calls.complete, 0, 'the deadline belongs to the backend; the FE never completes')
}

// ---- 3c. 到点只问一次后端，不重复 ----
{
  const clock = makeClock()
  const expiresAt = new Date(clock.now() + 600_000).toISOString()
  const { engine, calls } = setup({
    clock, expiresAt, durationMinutes: 10,
    getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
  })
  await engine.startSession()
  await clock.runTimeouts()
  const before = calls.getReport

  clock.advance(700)
  await clock.tickInterval(1)
  await clock.runTimeouts()
  assert.equal(calls.getReport, before + 1, 'exactly one query at the local deadline')

  await clock.tickInterval(3)
  await clock.runTimeouts()
  assert.equal(calls.getReport, before + 1, 'and only one — the interval is already stopped')
}

// ---- 3d. 没有截止时刻（不限时）：不起 interval，不显示计时 ----
{
  const clock = makeClock()
  const { engine } = setup({ clock, expiresAt: null })
  await engine.startSession()
  await clock.runTimeouts()
  assert.equal(engine.elapsedPracticeSeconds.value, null, 'no deadline means no timer to show')
  assert.equal(clock.intervals.size, 0, 'no interval may be started without a deadline')
}

// ---- 3e. 后端单方面结束，showReport 开：转结果页流程（spec §9.2.3 第 2 条）----
{
  const clock = makeClock()
  const expiresAt = new Date(clock.now() + 600_000).toISOString()
  const { engine, completed } = setup({
    clock, expiresAt, durationMinutes: 10, showReport: true,
    getReport: async () => reportOf(['completed', 'completed'], 'generating', { expiresAt, status: 'completed' }),
  })
  await engine.startSession()
  await clock.runTimeouts()

  clock.advance(700)
  await clock.tickInterval(1)
  await clock.runTimeouts()

  assert.equal(engine.stage.value, 'report-loading', 'a server-side end must open the report gate')
  assert.deepEqual(completed, ['sess-1'], 'the teacher must still get the broadcast')
}

// ---- 3f. 后端单方面结束，showReport 关：留在练习页 + 合上只读锁（§9.2.1）----
{
  const clock = makeClock()
  const expiresAt = new Date(clock.now() + 600_000).toISOString()
  const { engine } = setup({
    clock, expiresAt, durationMinutes: 10, showReport: false,
    getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
  })
  await engine.startSession()
  await clock.runTimeouts()

  clock.advance(700)
  await clock.tickInterval(1)
  await clock.runTimeouts()

  assert.equal(engine.stage.value, 'reading', 'hidden reports keep the student on the practice page')
  assert.equal(engine.sessionEnded.value, true, 'the read-only lock must engage')
  assert.equal(engine.error.value, null, 'a server-side end is not an error')
}

// ---- 3g. 本地已 complete 过：后端的结束信号不得再广播一次 ----
{
  const clock = makeClock()
  const expiresAt = new Date(clock.now() + 600_000).toISOString()
  const { engine, completed } = setup({
    clock, expiresAt, durationMinutes: 10, showReport: true,
    getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
  })
  await engine.startSession()
  await clock.runTimeouts()
  await engine.completeAndShowReport()
  await clock.runTimeouts()

  clock.advance(700)
  await clock.tickInterval(1)
  await clock.runTimeouts()

  assert.deepEqual(completed, ['sess-1'], 'exactly one completion broadcast')
}

// ---- 3h. resume 重启显示计时器，算出的是真实已用时长 ----
{
  const clock = makeClock()
  // 截止时刻在 4 分钟后，配置 10 分钟 → 已经过了 6 分钟
  const expiresAt = new Date(clock.now() + 240_000).toISOString()
  const { engine } = setup({ clock, expiresAt, durationMinutes: 10 })

  engine.resumeFromReport(reportOf(['pending', 'pending'], null, { expiresAt }))
  assert.equal(engine.elapsedPracticeSeconds.value, 360,
    'resume must show real elapsed time, not reset to zero')
}

// ---- 4. 后台标签页不发 GET；回到前台继续；退避不因隐藏而推进 ----
{
  const clock = makeClock()
  const doc = makeDoc(true)
  const { engine, calls } = setup({ clock, doc, statuses: ['generating'], reports: [reportOf(['generating'])] })
  await engine.startSession()
  await clock.runTimeouts()
  assert.equal(calls.getReport, 0, 'hidden tab must not issue GET')

  doc.hidden = false
  doc.fire('visibilitychange')
  await clock.runTimeouts()
  assert.ok(calls.getReport >= 1, 'returning to foreground must resume polling')
}
{
  // 退避序列 1s/2s/4s/8s 封顶
  assert.equal(Math.min(8000, 1000 * 2 ** 0), 1000)
  assert.equal(Math.min(8000, 1000 * 2 ** 5), 8000)
}

// ---- 5. overallStatus=null 且无 generating 段可进 report；ready 前保持 report-loading ----
{
  const clock = makeClock()
  const { engine } = setup({
    clock,
    statuses: ['completed'],
    reports: [reportOf(['generating'], null), reportOf(['completed'], null)],
  })
  await engine.startSession()
  await clock.runTimeouts()

  await engine.completeAndShowReport()
  await clock.runTimeouts()
  assert.equal(engine.stage.value, 'report-loading', 'must not reveal a partial report while generating')

  await clock.runTimeouts()
  assert.equal(engine.stage.value, 'report')
}

// ---- 5b. 段落全 completed 但 overallStatus='generating'（真实 complete 窗口）：
//         必须持续轮询直到 overall 落定，不得卡在 report-loading ----
{
  const clock = makeClock()
  const { engine, calls } = setup({
    clock,
    statuses: ['completed'],
    reports: [
      reportOf(['completed'], 'generating'),
      reportOf(['completed'], 'generating'),
      reportOf(['completed'], 'completed'),
    ],
  })
  await engine.startSession()
  await clock.runTimeouts()

  await engine.completeAndShowReport()
  await clock.runTimeouts()
  assert.equal(engine.stage.value, 'report-loading', 'overallStatus=generating must keep the loading gate closed')

  const midGets = calls.getReport
  await clock.runTimeouts()
  assert.ok(calls.getReport > midGets, 'must keep polling while overallStatus is still generating')

  await clock.runTimeouts()
  assert.equal(engine.stage.value, 'report', 'report must appear once overallStatus settles to completed')
}

// ---- 6. 全段评完静默 complete 一次，stage 仍是 reading；随后看报告不再 POST ----
{
  const clock = makeClock()
  const { engine, calls, completed } = setup({
    clock,
    statuses: ['completed'],
    reports: [reportOf(['completed'], 'completed')],
  })
  await engine.startSession()
  await clock.runTimeouts()
  await engine.completeAndShowReport()
  await clock.runTimeouts()

  const afterFirst = calls.complete
  assert.equal(afterFirst, 1, 'completion must be issued exactly once')
  assert.equal(completed.length, 1, 'onSessionCompleted must fire once')

  await engine.completeAndShowReport()
  await clock.runTimeouts()
  assert.equal(calls.complete, 1, 'viewing the report again must not re-POST complete')
}

// ---- 7. POST 之前抓到的 report 不得满足最终 gate ----
{
  const clock = makeClock()
  const { engine, calls } = setup({
    clock,
    statuses: ['completed'],
    reports: [reportOf(['completed'], null)],
  })
  await engine.startSession()
  await clock.runTimeouts()
  // 没有 generating 段就不启动轮询，所以此刻静默预热还没有机会跑
  assert.equal(calls.complete, 0)
  assert.equal(calls.getReport, 0, 'a report fetched before the POST cannot satisfy the final gate')

  await engine.completeAndShowReport()
  await clock.runTimeouts()
  assert.equal(engine.stage.value, 'report')
  assert.ok(calls.getReport >= 1, 'at least one GET must be issued after the completion POST')
  assert.equal(calls.complete, 1, 'completion is issued exactly once')
}

// ---- 8. 取消录音：abort 流、回到 idle、不产生评分任务 ----
{
  const clock = makeClock()
  const { engine, streams } = setup({ clock })
  await engine.startSession()
  await clock.runTimeouts()
  await engine.startRecording()

  await engine.cancelRecording()

  assert.equal(streams[0].stopped, false, 'cancel must not send stop, so no scoring task is created')
  assert.match(streams[0].aborted, /cancel/i)
  assert.equal(engine.recordingState.value, 'idle')
  assert.equal(engine.currentParagraphIdx.value, 0, 'cancel must not advance to the next paragraph')
  assert.equal(engine.localAudioByParagraph.value.size, 0, 'a cancelled attempt leaves no replayable audio')
}

// ---- showReport=false：completed 的 session 不进结果页，留在练习页 ----
{
  const { engine } = setup({ showReport: false })
  engine.resumeFromReport({ ...reportOf(['completed'], 'completed'), status: 'completed' })
  assert.equal(engine.stage.value, 'reading', 'showReport 关闭时 completed 也留在 reading')
  assert.equal(engine.sessionEnded.value, true, 'sessionEnded 仍为 true —— 只读锁靠它')
}

// ---- showReport=true：completed 的 session 照旧进结果页 ----
{
  const { engine } = setup({ showReport: true })
  engine.resumeFromReport({ ...reportOf(['completed'], 'completed'), status: 'completed' })
  assert.equal(engine.stage.value, 'report', 'showReport 打开时行为不变')
}

// ---- restartSession：先 abandon 再回起始页 ----
{
  const abandoned = []
  const { engine } = setup({
    abandonSession: async id => { abandoned.push(id); return { sessionId: id, status: 'abandoned' } },
  })
  engine.resumeFromReport(reportOf(['pending', 'pending']))
  assert.equal(engine.stage.value, 'reading')

  const ok = await engine.restartSession()
  assert.equal(ok, true)
  assert.deepEqual(abandoned, ['sess-1'], '把旧 session 送去 abandon')
  assert.equal(engine.stage.value, 'ready', '回起始页')
  assert.equal(engine.report.value, null, 'report 已清空')
}

// ---- restartSession 失败：不重置，返回 false，旧 session 仍然有效 ----
{
  const { engine } = setup({
    abandonSession: async () => { throw new Error('network down') },
  })
  engine.resumeFromReport(reportOf(['pending', 'pending']))

  const ok = await engine.restartSession()
  assert.equal(ok, false)
  assert.equal(engine.stage.value, 'reading', '失败不能把学生踢回起始页')
  assert.equal(engine.report.value?.sessionId, 'sess-1', '旧 session 仍在')
  assert.equal(engine.error.value, null, '失败只回 false，文案由调用方决定')
}

// ---- 录音中「重新开始」：录音器必须真的拆掉 ----
// 只摘 onChunk 不够：mediaStream track、AudioContext、worklet、时长 interval 都还活着，
// 孤儿 worklet 会继续把音频灌进下一段录音（上传的 WAV 与 WS 流都成了两套采集图交织）。
{
  const clock = makeClock()
  const { engine, recorder, streams } = setup({ clock })
  await engine.startSession()
  await clock.runTimeouts()
  await engine.startRecording()
  assert.equal(recorder.cleaned, 0, '录音进行中，还没到拆的时候')

  const ok = await engine.restartSession()
  assert.equal(ok, true)
  assert.equal(recorder.cleaned, 1, '重新开始必须拆掉录音器，不能只摘 onChunk')
  assert.equal(recorder.onChunk.value, null, 'onChunk 也要摘')
  assert.ok(streams[0].aborted, 'WS 流一并中止')
  assert.equal(engine.stage.value, 'ready')
}

// ---- 「重新开始」成功后广播作废，老师端才会把「已完成」刷回「未开始」----
{
  const abandonedBroadcasts = []
  const { engine } = setup({
    onSessionAbandoned: id => abandonedBroadcasts.push(id),
  })
  engine.resumeFromReport(reportOf(['pending', 'pending']))
  await engine.restartSession()
  assert.deepEqual(abandonedBroadcasts, ['sess-1'], '成功作废要通知调用方去广播')
}

// ---- 作废失败不广播 ----
{
  const abandonedBroadcasts = []
  const { engine } = setup({
    abandonSession: async () => { throw new Error('network down') },
    onSessionAbandoned: id => abandonedBroadcasts.push(id),
  })
  engine.resumeFromReport(reportOf(['pending', 'pending']))
  await engine.restartSession()
  assert.deepEqual(abandonedBroadcasts, [], '没作废成功就不能报「这一轮不算了」')
}

console.log('article reading engine: all cases passed')

// ---- 错误本地化：每个出口都是本地化文案，绝不是响应体（spec §9.2.5 A1）----

const LEAK = 'Session not found'

// 1. startSession
{
  const clock = makeClock()
  const { engine } = setup({ clock, createSession: async () => { throw new Error(LEAK) } })
  await engine.startSession()
  assert.equal(engine.error.value, '__localized_session_not_found__',
    'a failed start must not show the backend detail')
}

// 5. pollOnce
{
  const clock = makeClock()
  const { engine } = setup({
    clock,
    statuses: ['generating'],
    getReport: async () => { throw new Error(LEAK) },
  })
  await engine.startSession()
  await clock.runTimeouts()
  assert.equal(engine.error.value, '__localized_session_not_found__')
}

// 6. playDemoAudio
{
  const clock = makeClock()
  // 必须经 setup 注入：引擎把 overrides 展开进一个**新对象**，
  // 构造之后再改外面那个 deps 是改不到它的。
  const { engine } = setup({ clock, getDemoAudio: async () => { throw new Error(LEAK) } })
  await engine.startSession()
  await clock.runTimeouts()
  await engine.playDemoAudio(0)
  assert.equal(engine.error.value, '__localized_session_not_found__')
}

// 4. completeAndShowReport
{
  const clock = makeClock()
  const { engine } = setup({ clock, completeSession: async () => { throw new Error(LEAK) } })
  await engine.startSession()
  await clock.runTimeouts()
  await engine.completeAndShowReport()
  assert.equal(engine.error.value, '__localized_session_not_found__')
}

// 2. startRecording（麦克风；不是后端 detail → 走兜底）
{
  const clock = makeClock()
  const { engine, recorder } = setup({ clock })
  await engine.startSession()
  await clock.runTimeouts()
  recorder.startRecording = async () => { throw new Error('NotAllowedError') }
  await engine.startRecording()
  assert.equal(engine.error.value, '__localized_generic__',
    'an unmapped cause must fall back, never surface raw')
}

// 409 的分支逻辑保留（spec §9.2.5 第 4 条），同时文案也本地化
{
  const clock = makeClock()
  const gone = Object.assign(new Error('Session is abandoned'), { status: 409 })
  const { engine } = setup({ clock, completeSession: async () => { throw gone } })
  await engine.startSession()
  await clock.runTimeouts()
  await engine.completeAndShowReport()
  assert.equal(engine.sessionInvalid.value, true, '409 must still flag sessionInvalid')
  assert.ok(!String(engine.error.value).includes('{'), 'never any JSON in user-facing copy')
}

console.log('article reading engine: error localization cases passed')

// ---- 轮询失败上限（spec §9.2.5 待办 Unsure-1）----

// 后端持续不应答 → 敲满上限就停手，不再每 8 秒一次敲到学生关页面
{
  const clock = makeClock()
  const { engine, calls } = setup({
    clock,
    statuses: ['generating'],
    getReport: async () => { throw new Error('boom') },
  })
  await engine.startSession()
  for (let i = 0; i < 30; i++) await clock.runTimeouts()

  assert.equal(calls.getReport, 8, 'must give up after MAX_POLL_FAILURES consecutive failures')
  assert.equal(clock.timeouts.size, 0, 'no timer may be left armed after giving up')
  assert.ok(engine.error.value, 'the banner must stay so the student knows why it stopped')
}

// 中途成功一次 → 失败计数归零，健康的长轮询不会被误掐
{
  const clock = makeClock()
  let n = 0
  const clockRef = clock
  const { engine, calls } = setup({
    clock: clockRef,
    statuses: ['generating'],
    getReport: async () => {
      n += 1
      // 前 5 次失败，第 6 次成功（仍 generating），之后继续失败
      if (n === 6) return reportOf(['generating'])
      throw new Error('boom')
    },
  })
  await engine.startSession()
  for (let i = 0; i < 40; i++) await clock.runTimeouts()

  // 5 次失败 + 1 次成功（归零）+ 8 次失败 = 14
  assert.equal(calls.getReport, 14, 'a successful response must reset the failure budget')
}

// 停手之后，新一轮（schedulePoll(0)）要拿到新的失败预算
{
  const clock = makeClock()
  const { engine, calls } = setup({
    clock,
    statuses: ['generating'],
    getReport: async () => { throw new Error('boom') },
  })
  await engine.startSession()
  for (let i = 0; i < 30; i++) await clock.runTimeouts()
  assert.equal(calls.getReport, 8)

  // 学生做了点什么（录完一段 / 点重试）→ 重新起一轮
  engine.resumeFromReport(reportOf(['generating']))
  for (let i = 0; i < 30; i++) await clock.runTimeouts()
  assert.equal(calls.getReport, 16, 'a fresh chain must get a fresh budget, not stop on failure #1')
}

console.log('article reading engine: poll failure cap cases passed')

// ---- 轮询横幅不滞留（spec §9.2.5 待办 Unsure-3）----

// 失败一次 → 横幅出现；下一次成功 → 横幅收走，不留在学生眼前
{
  const clock = makeClock()
  let n = 0
  const { engine } = setup({
    clock,
    statuses: ['generating'],
    getReport: async () => {
      n += 1
      if (n === 1) throw new Error('boom')
      return reportOf(['generating'])
    },
  })
  await engine.startSession()
  await clock.runTimeouts()
  assert.ok(engine.error.value, 'a transient poll failure must raise the banner')

  await clock.runTimeouts()
  assert.equal(engine.error.value, null, 'a later successful poll must take its own banner down')
}

// 但**只**收自己竖的那面：录音被拒的解释必须活过随后那次成功轮询。
// finishRecording 的 catch 设完 error 就 schedulePoll(0)（§4.4 到期收口），
// 无差别清 error 会让学生在唯读锁合上的同一瞬间失去「为什么录不了」的唯一说明。
{
  const clock = makeClock()
  const { engine } = setup({ clock, getReport: async () => reportOf(['pending', 'pending']) })
  await engine.startSession()
  await clock.runTimeouts()

  engine.error.value = '__recording_rejected__'
  await clock.runTimeouts()   // 成功轮询跑一轮
  assert.equal(engine.error.value, '__recording_rejected__',
    'a non-poll error must survive a successful poll')
}

console.log('article reading engine: stale banner cases passed')

// ---- 编辑态预览：userId 原样透传给 createSession，null 不得被改写成空串（spec §4.5）----
{
  const clock = makeClock()
  const seen = []
  const { engine } = setup({
    clock,
    userId: null,
    createSession: async (configId, uid) => {
      seen.push([configId, uid])
      return reportOf(['pending', 'pending'])
    },
  })
  await engine.startSession()

  assert.deepEqual(seen, [['c1', null]],
    'editor preview must reach the backend as null, not "" — an empty string is a student named ""')
}

{
  const clock = makeClock()
  const seen = []
  const { engine } = setup({
    clock,
    userId: 'u9',
    createSession: async (configId, uid) => {
      seen.push([configId, uid])
      return reportOf(['pending', 'pending'])
    },
  })
  await engine.startSession()

  assert.deepEqual(seen, [['c1', 'u9']], 'a real student id must still be forwarded verbatim')
}

// ---- 双击「开始朗读」只发一个 POST /session ----
// 后端 get-or-create 无锁、无唯一约束：两个并发请求会双双查不到既有 session 然后各插一行。
// 编辑态预览（userId=null）必然如此 —— `_latest_session` 对空 user 早退返回 None（spec §4.5）。
// 多出来那一行会算进 §4.3 B 的「N 名学生正在练习中」直到过期，还会多写一笔作业记录（§9.2.4）。
{
  const clock = makeClock()
  let calls = 0
  let release
  const gate = new Promise(resolve => { release = resolve })
  const { engine } = setup({
    clock,
    userId: null,
    createSession: async () => {
      calls += 1
      await gate
      return reportOf(['pending', 'pending'])
    },
  })

  const first = engine.startSession()
  await flush()
  assert.equal(engine.starting.value, true, 'POST 在途时 starting 必须为真，视图据此置灰')

  const second = engine.startSession()   // 第二次点击：必须被闩早退吃掉
  release()
  await Promise.all([first, second])

  assert.equal(calls, 1, 'double click must not create a second session')
  assert.equal(engine.starting.value, false, '结束后闩必须放开，失败重试才点得动')
}

// ---- 开始失败后闩要放开：学生还能再点一次（承 §9.2.5 A2 的 readyError 出口）----
{
  const clock = makeClock()
  let calls = 0
  const { engine } = setup({
    clock,
    createSession: async () => {
      calls += 1
      if (calls === 1) throw new Error('Config not found')
      return reportOf(['pending', 'pending'])
    },
  })

  await engine.startSession()
  assert.equal(engine.stage.value, 'ready', '失败停在起始页')
  assert.ok(engine.error.value, '失败必须有文案')
  assert.equal(engine.starting.value, false, 'finally 放闩，不是只在成功路径上放')

  await engine.startSession()
  assert.equal(calls, 2, '第二次点击必须真的发出去')
  assert.equal(engine.stage.value, 'reading')
}
