import { applyDocumentCaptionTiming } from '@lincyaw/kernel'
import type { DocumentCaptionTimingAction } from '@lincyaw/kernel'
import { reconcileCaptionTiming } from './caption-timing.ts'
import type { ProjectOperation, ProjectPlan } from '@lincyaw/kernel'
import { undoDepth, redoDepth } from '@tiptap/pm/history'
import { TextSelection } from '@tiptap/pm/state'
import { CommentDraftBank } from './document-editor/comment-drafts.ts'
import { ComponentPropertyDrafts } from './component-property-drafts.ts'
import type { ComponentPropertyDraft } from './component-property-drafts.ts'
import { componentSource, reconcileComponentAction, sameComponentSource } from './component-properties.ts'
import { applyDocumentComponentAction, documentComponentSection, effectiveComponentParameters } from '@lincyaw/kernel'
import type { DocumentAuthoringActionPlan, DocumentComponentAction, AppliedDocumentAuthoringAction } from '@lincyaw/kernel'
import { inspectCommentDrafts } from './document-editor/comment-draft-inspection.ts'
import { mutateCommentDrafts } from './document-editor/comment-draft-actions.ts'
import { inspectComponentDrafts } from './component-draft-inspection.ts'
import { mutateComponentDrafts } from './component-draft-actions.ts'
import { DocumentSession } from './document-session.ts'
import { reconcileRecordingAdoption } from './recording-adoption.ts'
import { disposeTranscriptionSessions } from './transcription-session.ts'
import { documentRecordingActive, disposeDocumentRecordings } from './audio-recording.ts'
import { DOCUMENT_AUDIO_MAX_BYTES } from '@lincyaw/kernel'
import type { DocumentSessionState } from './document-session.ts'
import type { DocumentDraftReference } from './document-drafts.ts'
import { DocumentReturnPoints, documentReturnStep, documentViewportOffset, restoreDocumentViewport } from './document-return-point.ts'
import type { DocumentReturnPoint } from './document-return-point.ts'
import type { DocumentRecordingAdoption, VerifiedDocumentRecordingAdoption, DocumentAudioImportAnswer, DocumentAudioInspectionAnswer, DocumentChangeAnswer, MediaClip, NarrationNode, TeachingDocument, DocumentTranscriptionRequest, TranscriptionTask, TranscriptionAdoptionDraft, TranscriptionAdoptionApply, TranscriptionAdoptionPlan, TranscriptionAdoptionAnswer } from '@lincyaw/kernel'
import type { NativeDocumentEditor } from './document-editor/schema.tsx'
import type { DocumentEditorCommands } from './document-editor/DocumentEditor.tsx'
import type { DocumentInsertionPlace } from './document-editor/insertion-place.ts'
import { nativeBlockText, nativeTextPoint, nativeTextPosition } from './document-editor/selection.ts'
import type { NativeTextPoint } from './document-editor/selection.ts'
import type { DocumentFieldBookmark, DocumentFieldRegistration, DocumentHistoryEditor } from './document-editor/focus.ts'
import { duplicateDocumentPage, moveDocumentPage, pageDeletion, removeDocumentPage, renameDocumentPage } from '@lincyaw/kernel'
import { changeStepTiming } from './step-production.ts'
import { changeDocumentVoice } from './document-voices.ts'
import type { DocumentVoiceOptions, DocumentVoiceScope } from './document-voices.ts'
import type { CanonicalSource, VoiceoverJob, VoiceSource } from '@lincyaw/kernel'
import type { StepDurationDraft, StepDurationKey, StepTimingPatch } from './step-production.ts'
import { DocumentTimingDrafts } from './document-timing-drafts.ts'
import { CaptionTimingDrafts } from './caption-timing-drafts.ts'
import type { CaptionTimingDraft } from './caption-timing-drafts.ts'
import { compileDocumentPreview, readPreviewWhenReady, waitForPreviewRequest } from './document-preview.ts'
import { readDocumentSnapshot } from './document-preview.ts'
import { errorMessage, sameProductionBinding, slideShape, stepKeys, stepTrail, trailStep } from '@lincyaw/kernel'
import type { Diagnostic, DiagnosticSnapshot, DocumentOperation, DocumentsAnswer, Focus, FocusReport, IrAnswer, IrEvent, IRDocument, JobsAnswer, OperationAnswer, PreviewAnchor, PreviewAnswer, PreviewCommand, PreviewDiagnostics, PreviewPoint, PreviewPosition, PreviewRect, PreviewRenderer, PreviewReport, ReviewChange, ReviewSnapshot, RevisionsAnswer, UncommittedFile } from '@lincyaw/kernel'
import { DocumentDiagnostics } from './diagnostics.ts'
import { DocumentHistory } from './history.ts'
import { DocumentJobs } from './jobs.ts'
import { VoiceoverJobs } from './voiceover-jobs.ts'
import { contentText, localeOf, MESSAGES } from './messages.ts'
import type { AgentPlace, Locale } from './messages.ts'
import { changedBlocks, changedPlaces, findPlaceAtLine, findPlaceSource } from './places.ts'
import { OperationLog } from './telemetry.ts'
import type { OperationValue } from './telemetry.ts'
import { PreviewPlayback } from './preview-playback.ts'
import { ProductionPlayback } from './production-playback.ts'
import type { ProductionBinding, ProductionPlan, ProductionPlaybackCommand, ProductionPlaybackState } from '@lincyaw/kernel'
import { workspaceClient, WorkspaceServerError } from './workspace-client.ts'
import type { WorkspaceClient } from './workspace-client.ts'
import type { PageDeletePlan } from '@lincyaw/kernel'
import type { AuthoringClientAction, AuthoringClientActionResult, AuthoringClientSnapshot } from '@lincyaw/kernel'
import { PRODUCTION_PLAYBACK_RATES } from '@lincyaw/kernel'

/** `editing`: the canonical document is edited in the parent; `viewing`: a derived snapshot is presented. */
export type WorkbenchMode = 'editing' | 'viewing'

/**
 * How the web form is shown: as a page or as its presentation, whose controls can play a configured lecture.
 * Both share one preview page and switch in place with `go.view`.
 */
export type WebView = 'document' | 'present'

/** A playback key pressed in a preview that the workbench plays: Space, and the left and right arrows. */
export type PlaybackKey = 'play-pause' | 'previous' | 'next'

/** What the author points at in the document: a block (or a section), or a passage selected in it. */
export interface Pointer {
  target: PreviewAnchor
  step?: number
  /** The selected passage. */
  quote?: string
  /** Its box in the preview's viewport; none while it is out of sight. */
  rect?: PreviewRect
  obstacles?: PreviewRect[]
  point?: PreviewPoint
  /** Its text can be edited in place. */
  isEditable: boolean
}

export type SaveState =
  | { kind: 'idle' }
  | { kind: 'saving' }
  | { kind: 'saved', at: string }
  | { kind: 'locked' }
  | { kind: 'failed', message: string }

/** A request for the agent of the conversation: its text and what it is about. */
export interface AgentRequest {
  text: string
  focus: FocusReport[]
}

/** A request for the author to finish and send in the conversation, and what it is about: the host only shows it. */
export interface AgentDraft {
  text: string
  focus: FocusReport[]
}

/** Where the host put a request: the conversation's session and the request's id, which its user message carries. */
export interface AgentAnswer {
  session: string
  request: string
}

export interface WorkbenchState {
  locale: Locale
  authoring?: DocumentSessionState
  /** Unaccepted numeric form input is retained separately from saved canonical timing. */
  hasDocumentTimingDrafts?: boolean
  documentTimingDraftStorageFailure?: boolean
  captionTimingDraftSteps?: string[]
  captionTimingDraftStorageFailure?: boolean
  isDark: boolean
  documents?: DocumentsAnswer
  documentsFailure?: string
  /** The workspace's routes answer 404: `@lincyaw/dsh` is turned off on the plugins page. */
  isBundleDisabled?: boolean
  document?: string
  ir?: IrAnswer
  mode: WorkbenchMode
  previewRenderer: PreviewRenderer
  /** In the web form: the page or the presentation, both in the same page, switched without loading it again. */
  webView: WebView
  previewReload: number
  preview?: PreviewAnswer
  previewFailure?: string
  /** The IR revision the preview shows. */
  previewIrRevision?: string
  isPreparingPresentation?: boolean
  presentationFailure?: string
  presentationSnapshot?: { version: string, fingerprint: string, revision: string, project: number }
  productionPlan?: ProductionPlan
  productionPlayback?: ProductionPlaybackState
  productionFailure?: string
  voiceoverJobs?: VoiceoverJob[]
  voiceoverJobsFailure?: string
  /** A cache refresh that actually changed the current production binding; it never starts playback. */
  voiceoverCacheRefresh?: { source: CanonicalSource, revision: string, sequence: number }
  /** Changes when an acknowledged preview connection is attached or detached, including same-revision reloads. */
  previewConnection: number
  /** Where the preview is: the section the author reads, or the slide and its step. */
  position?: PreviewPosition
  /** Changes on every position intent, including A → B → A, without changing the source. */
  documentPositionToken: string
  /** Explicit author navigation, distinct from the player's passive cursor and frame reload reports. */
  productionNavigationVersion: number
  documentPositionIntent: 'seek' | 'pause'
  /** An explicit source navigation request; scrolling updates position without issuing another request. */
  documentNavigation?: { section: string, block?: string, request: string }
  documentPositionFailure?: 'removed'
  isCreatingPage?: boolean
  isEditingPage?: boolean
  editorHistory?: EditorHistoryState
  isHistoryPreparing?: boolean
  pageEditFailure?: string
  diagnostics?: DiagnosticSnapshot
  isDiagnosticsUnavailable: boolean
  canRepairDiagnostics: boolean
  isRepairingDiagnostics: boolean
  diagnosticsRepairFailure?: string
  pointer?: Pointer
  /** Where the blocks the AI just changed are in the preview, for the kitten to walk to; `id` is new for each change. */
  changeTrail?: { id: number, rects: PreviewRect[] }
  /** The places the AI just changed, for the kitten in the editor to walk to; `id` is new for each change. */
  changedPlaces?: { id: number, targets: PreviewAnchor[] }
  save: SaveState
  /** What the conversation's agent is told the author points at. */
  focus: FocusReport[]
  isFocusPending: boolean
  focusFailure?: string
  review?: ReviewSnapshot
  history?: RevisionsAnswer
  /** Files changed elsewhere and not yet in the history. */
  outside?: UncommittedFile[]
  historyFailure?: string
  isHistoryBusy: boolean
  isAsking: boolean
  askFailure?: string
  /** What the document can be made into, and its builds and exports (the author's and the AI's). */
  jobs?: JobsAnswer
  jobsFailure?: string
  voiceServiceVersion?: number
  jobPreparing?: { target: string, canCancel: boolean }
}

export interface PreviewSink {
  readonly generation?: number
  readonly capabilities?: readonly PreviewCommand['type'][]
  send(command: PreviewCommand): void
}

export interface EditorHistoryState { section: string, undo: boolean, redo: boolean, field?: boolean }

type Listener = () => void
const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint

/** The workbench's state as its parts read and change it. */
export interface WorkbenchStore {
  readonly state: WorkbenchState
  set(patch: Partial<WorkbenchState>): void
}

// The export of the lecture as a video (`@lincyaw/addon-lecture`).
export const VIDEO_TARGET = 'video'

/** The document the conversation works on: none when no document is open. */
export function selectedDocument(state: WorkbenchState): string | null {
  return state.document ?? null
}

/** Whether the AI is changing the workspace now, or its change waits for the author. */
export function isAgentChanging(state: WorkbenchState): boolean {
  return Boolean(state.review && (state.review.running || state.review.changes.length > 0))
}

/** Whether the document has a slides form (no renderers listed: every form). */
export function hasSlides(ir: IrAnswer | undefined): boolean {
  const renderers = ir?.doc.config.renderers
  return !renderers || Object.keys(renderers).length === 0 || 'slides' in renderers
}

/** The width of the document's slides over their height (`renderers.slides.aspectRatio`), 16:9 by default. */
export function slideAspect(ir: IrAnswer | undefined): number {
  if (!ir) return 16 / 9
  const { width, height } = slideShape(ir.doc)
  return width / height
}

/** What opening or closing a document clears. */
const DOCUMENT_RESET = {
  mode: 'editing',
  authoring: undefined,
  isCreatingPage: false,
  isEditingPage: false,
  editorHistory: undefined,
  isHistoryPreparing: false,
  pageEditFailure: undefined,
  ir: undefined, preview: undefined, previewFailure: undefined, previewIrRevision: undefined, position: undefined, documentNavigation: undefined, documentPositionFailure: undefined,
  diagnostics: undefined, isDiagnosticsUnavailable: false, isRepairingDiagnostics: false, diagnosticsRepairFailure: undefined,
  pointer: undefined, save: { kind: 'idle' },
  focus: [], isFocusPending: false, focusFailure: undefined,
  review: undefined, history: undefined, outside: undefined, historyFailure: undefined, isHistoryBusy: false, askFailure: undefined,
  jobs: undefined, jobsFailure: undefined, jobPreparing: undefined,
  voiceoverJobs: undefined, voiceoverJobsFailure: undefined, voiceoverCacheRefresh: undefined,
} satisfies Partial<WorkbenchState>

/** A free directory for a new document called `title`, beside most of the existing ones. */
export function documentDirFor(title: string, taken: readonly string[]): string {
  const parents = new Map<string, number>()
  for (const dir of taken) {
    const parent = dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : ''
    parents.set(parent, (parents.get(parent) ?? 0) + 1)
  }
  const parent = [...parents].sort((a, b) => b[1] - a[1])[0]?.[0] ?? ''
  const slug = title.normalize('NFKD').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'document'
  const base = parent ? `${parent}/${slug}` : slug
  let dir = base
  for (let n = 2; taken.includes(dir); n++) dir = `${base}-${n}`
  return dir
}

/**
 * The state of the workbench and its actions: the documents, the open document's preview, what the author points at
 * in it; canonical source editing, diagnostics and history are parts of their own
 * (`diagnostics.ts`, `history.ts`).
 */
export class Workbench {
  readonly client: WorkspaceClient
  readonly #documentSession: DocumentSession
  readonly #audioImports = new WeakMap<File, { identity: string, version: string, id: string }>()
  readonly #audioFingerprints = new WeakMap<File, Promise<string>>()
  readonly #documentTimingDrafts = new DocumentTimingDrafts()
  readonly #captionTimingDrafts = new CaptionTimingDrafts()
  readonly #removedCueTimings = new Map<string, { pauseMs: number }>()
  #cueTimingScope?: string
  #documentEditor?: NativeDocumentEditor
  #documentEditorCommands?: DocumentEditorCommands
  #documentEditorBinding?: { identity: string, reset: number }
  readonly #documentReturnPoints = new DocumentReturnPoints()
  #documentViewport?: { element: HTMLElement, identity: string, returnScrollTop?: number, stop(): void }
  #stopDocumentReturnScroll?: () => void
  #openingDocument?: { directory: string, scope: number, generation: number, focus?: 'body', initial?: boolean, stop(): void,
    basis?: { identity: string, reset: number, version?: string, content: TeachingDocument }, point?: DocumentReturnPoint, fallback?: DocumentReturnPoint }
  #stopDocumentLayoutScroll?: () => void
  #activeDocumentHistory?: DocumentHistoryEditor
  #unsubscribeDocumentHistory?: () => void
  #unsubscribeDocumentSelection?: () => void
  #documentSelectionGeneration = 0
  #removedDocumentPosition?: { identity: string, documentId: string, sectionId: string, stepId?: string, version: string, fingerprint: string, scope: number, generation: number, token: string }
  #pendingDocumentFocus?: { documentId: string, nodeId: string, generation: number, afterSave?: true }
  #pendingDocumentSelection?: { documentId: string, anchor: NativeTextPoint, head: NativeTextPoint, focused: boolean }
  readonly #documentFields = new Map<string, DocumentFieldRegistration>()
  #pendingDocumentField?: { field: Omit<DocumentFieldRegistration, 'focus' | 'editor'>, bookmark: DocumentFieldBookmark, reset: number, generation: number }
  #state: WorkbenchState
  readonly #listeners = new Set<Listener>()
  readonly #commentDrafts = new CommentDraftBank()
  readonly #componentPropertyDrafts = new ComponentPropertyDrafts()
  readonly #positionSeed = Array.from(crypto.getRandomValues(new Uint32Array(2)), value => value.toString(36)).join('-')
  #positionSequence = 0
  readonly #selectedSteps = new Map<string, number>()
  #pendingGo?: { document: string | undefined, command: Extract<PreviewCommand, { type: 'go' }> }
  readonly #diagnostics: DocumentDiagnostics
  readonly #history: DocumentHistory
  readonly #jobs: DocumentJobs
  readonly #voiceover: VoiceoverJobs
  readonly #askers = new Set<(request: AgentRequest) => Promise<AgentAnswer | void>>()
  readonly #drafters = new Set<(draft: AgentDraft) => void | Promise<void>>()
  readonly #previewSinks = new Set<PreviewSink>()
  readonly #playbackKeys = new Set<(command: PlaybackKey) => void>()
  #unsubscribeIr?: () => void
  #runtimeDocument?: string
  #jobsDocument?: string
  readonly #jobObservers = new Set<symbol>()
  readonly #previewObservers = new Set<symbol>()
  #previewReads = 0
  #presentationPreparation = 0
  #presentationController?: AbortController
  #irReads = 0
  #focusVersion = 0
  #focusWriting: Promise<void> = Promise.resolve()
  #documentScope = 0
  #jobPreparation?: AbortController
  readonly #playback = new PreviewPlayback(() => ({ document: this.#state.document, scope: this.#documentScope, sinks: this.#previewSinks }))
  readonly #production = new ProductionPlayback(() => ({ document: this.#state.document, scope: this.#documentScope, sinks: this.#previewSinks,
    binding: this.#state.productionPlan?.binding, ready: this.#productionReady() }), state => this.#set({ productionPlayback: state }))
  /** A place to show that no preview could take yet (none was attached, e.g. the chat covered the document). */
  #pendingReveal?: PreviewAnchor
  /** The AI's changes to show on the document once the preview has them. */
  #pendingFlash?: ReviewChange[]
  #trail = 0
  #lastPosition?: string
  #awaitingView?: WebView
  /** The forms asked for ahead of the author (`web:<dir>`, `slides:<dir>`), and the wait before asking for the slides. */
  /** What the author does, for the operator's collector when the host has one. */
  readonly telemetry: OperationLog
  readonly #preparationTimeoutMs: number

  constructor(options: { client?: WorkspaceClient, locale?: Locale, isDark?: boolean, preparationTimeoutMs?: number } = {}) {
    this.client = options.client ?? workspaceClient()
    this.#preparationTimeoutMs = Number.isFinite(options.preparationTimeoutMs) ? Math.max(1000, Math.min(300_000, options.preparationTimeoutMs!)) : 45_000
    this.#state = {
      locale: options.locale ?? localeOf(globalThis.navigator?.language) ?? 'en',
      isDark: options.isDark ?? false,
      mode: 'editing',
      previewRenderer: 'web',
      webView: 'document',
      previewReload: 0,
      previewConnection: 0,
      documentPositionToken: this.#nextDocumentPosition(),
      productionNavigationVersion: 0,
      documentPositionIntent: 'seek',
      isDiagnosticsUnavailable: false,
      canRepairDiagnostics: false,
      isRepairingDiagnostics: false,
      save: { kind: 'idle' },
      focus: [],
      isFocusPending: false,
      isHistoryBusy: false,
      isAsking: false,
    }
    const workbench = this
    const store: WorkbenchStore = {
      get state() {
        return workbench.#state
      },
      set: patch => workbench.#set(patch),
    }
    this.#documentSession = new DocumentSession(this.client, authoring => {
      if (this.#state.document !== authoring.directory) return
      const pendingField = this.#pendingDocumentField
      if (pendingField && (pendingField.field.identity !== authoring.identity || authoring.reset > pendingField.reset
        || pendingField.field.blockId !== undefined && !authoring.content?.blocks[pendingField.field.blockId]
        || !authoring.content?.steps[pendingField.field.stepId])) this.#pendingDocumentField = undefined
      const cueTimingScope = JSON.stringify([authoring.identity, authoring.reset])
      if (this.#cueTimingScope !== cueTimingScope) { this.#removedCueTimings.clear(); this.#cueTimingScope = cueTimingScope }
      const previous = this.#state.authoring
      // A saved timestamp belongs to this client's acknowledged source version.
      // Remote adoption has no server modification time to display in its place.
      const adoptedCleanVersion = Boolean(previous?.identity && previous.identity === authoring.identity
        && previous.version && authoring.version && previous.version !== authoring.version
        && !previous.loading && !authoring.loading && !previous.dirty && !authoring.dirty && !previous.saving && !authoring.saving)
      const position = this.#state.position
      const selected = position && previous && previous.identity === authoring.identity ? previous.content?.sections[position.section]?.stepOrder[position.step] : undefined
      const selectedStep = selected ? authoring.content?.steps[selected] : undefined, selectedPage = selectedStep ? authoring.content?.sections[selectedStep.sectionId] : undefined
      const selectedIndex = selectedPage?.stepOrder.indexOf(selected!) ?? -1
      const moved = position && selectedStep && selectedIndex >= 0 && (position.section !== selectedStep.sectionId || position.step !== selectedIndex)
      const removed = this.#state.webView === 'document' && position?.view !== 'present' && position && previous?.identity === authoring.identity
        && (selected ? !selectedStep || selectedIndex < 0 : !authoring.content?.sectionOrder.includes(position.section))
      const restoredPosition = !removed && this.#restoredDocumentPosition(authoring)
      const scope = this.#documentScope, generation = this.#documentSelectionGeneration
      if (removed && authoring.identity) this.#documentReturnPoints.forget(authoring.identity)
      this.#set({ authoring,
        ...removed ? { position: undefined, documentNavigation: undefined, documentPositionFailure: 'removed' as const } : {},
        ...restoredPosition ? { documentPositionFailure: undefined } : {},
        ...moved ? { position: { ...position, section: selectedStep.sectionId, step: selectedIndex, trail: [] } } : {},
        hasDocumentTimingDrafts: Boolean(authoring.identity && authoring.content && this.#documentTimingDrafts.has(authoring.identity, authoring.content)),
        documentTimingDraftStorageFailure: this.#documentTimingDrafts.failed,
        captionTimingDraftSteps: authoring.identity && authoring.member?.id && authoring.content ? this.#captionTimingDrafts.steps({ identity: authoring.identity, memberId: authoring.member.id }, authoring.content) : [],
        captionTimingDraftStorageFailure: Boolean(authoring.identity && authoring.member?.id && this.#captionTimingDrafts.failed({ identity: authoring.identity, memberId: authoring.member.id })),
        save: authoring.error ? { kind: 'failed', message: authoring.error } : authoring.saving ? { kind: 'saving' } : !authoring.dirty && (previous?.dirty || previous?.saving) ? { kind: 'saved', at: new Date().toISOString() } : adoptedCleanVersion || this.#state.save.kind === 'failed' ? { kind: 'idle' } : this.#state.save })
      // The deletion owns the token produced above; source restoration only retires its notice.
      if (removed && previous?.identity && previous.content && previous.version && previous.fingerprint
        && !previous.loading && !previous.dirty && !previous.saving && !previous.restored && !previous.composing && !previous.recovering && !previous.error
        && scope === this.#documentScope && generation === this.#documentSelectionGeneration && !this.#state.position && this.#state.documentPositionFailure === 'removed') {
        this.#removedDocumentPosition = { identity: previous.identity, documentId: previous.content.id, sectionId: position.section,
          ...selected ? { stepId: selected } : {}, version: previous.version, fingerprint: previous.fingerprint, scope, generation, token: this.#state.documentPositionToken }
      }
      this.#finishOpeningDocument()
      // Native insertion can select a page before the source actor receives that new entity.
      if (position && previous && previous.identity === authoring.identity && !previous.content?.sections[position.section]
        && authoring.content?.sections[position.section]) this.#saveDocumentReturnPoint()
      if (this.#production.changed()) void this.pausePlayback().catch(() => {})
      const acknowledgedSourceChanged = !authoring.dirty && !authoring.loading && !authoring.error && !previous?.loading
        && previous?.identity === authoring.identity && Boolean(previous?.version && authoring.version)
        && (previous?.version !== authoring.version || previous?.fingerprint !== authoring.fingerprint)
      if (!authoring.dirty && previous?.dirty || acknowledgedSourceChanged) { void this.#history.read(authoring.directory); void this.loadDocuments() }
      // The source changed under an author with nothing unsaved while the AI holds a review: the AI changed it. The
      // editor's kitten walks to the blocks whose content differs.
      if (acknowledgedSourceChanged && !previous?.dirty && !previous?.saving && this.#state.review && previous?.content && authoring.content) {
        const targets = changedBlocks(previous.content, authoring.content)
        if (targets.length) this.#set({ changedPlaces: { id: ++this.#trail, targets } })
      }
    })
    this.#diagnostics = new DocumentDiagnostics(this.client, store)
    this.#jobs = new DocumentJobs(this.client, store)
    this.#voiceover = new VoiceoverJobs(this.client, store, (job, signal) => this.#refreshVoiceover(job, signal))
    this.#history = new DocumentHistory(this.client, store, (changes) => {
      this.#record('ai.change', { 'presmium.files': changes.map(change => change.path).join('\n'), 'presmium.added': changes.reduce((n, c) => n + c.added, 0), 'presmium.removed': changes.reduce((n, c) => n + c.removed, 0) })
      this.#pendingFlash = changes
      this.#flashChanges()
    })
    this.telemetry = new OperationLog(() => this.client.readTelemetry(), (batch, beacon) => this.client.sendTelemetry(batch, beacon), this.#state.locale)
    void this.telemetry.start()
  }

  /** Records an operation of the author, with the document, the view and the mode it was done in. */
  #record(name: string, attributes: Record<string, OperationValue> = {}): void {
    this.telemetry.record(name, { 'presmium.document': this.#state.document, 'presmium.view': this.#state.previewRenderer, 'presmium.mode': this.#state.mode, ...attributes })
  }

  /** Does an operation that takes time and records it once done, with its outcome and how long it took. */
  async #timed<T>(name: string, attributes: Record<string, OperationValue>, work: () => Promise<T>, outcome: (value: T) => string = () => 'done'): Promise<T> {
    const started = Date.now()
    try {
      const value = await work()
      this.#record(name, { ...attributes, 'presmium.outcome': outcome(value), 'presmium.ms': Date.now() - started })
      return value
    } catch (error) {
      this.#record(name, { ...attributes, 'presmium.outcome': 'failed', 'presmium.error': errorMessage(error), 'presmium.ms': Date.now() - started })
      throw error
    }
  }

  get state(): WorkbenchState {
    return this.#state
  }

  /** Page-lifetime document navigation only; not a canonical source or playback revision. */
  get documentNavigationVersion(): number { return this.#documentScope }

  documentCommentDrafts(identity: string) {
    const source = this.#state.authoring
    return source?.identity === identity && source.member?.id ? this.#commentDrafts.get(identity, source.member.id) : undefined
  }
  /** A status counter only: passive client reports never enumerate or serialize drafts. */
  authoringCommentDraftRevision(): number {
    const source = this.#state.authoring
    return source?.identity && source.member?.id ? this.#commentDrafts.peek(source.identity, source.member.id)?.revision ?? 0 : 0
  }
  documentPropertyDrafts(identity: string): ComponentPropertyDrafts | undefined { return this.#state.authoring?.identity === identity ? this.#componentPropertyDrafts : undefined }
  /** Only counters enter passive client state; no new form payload is exposed to MCP. */
  authoringDraftRevision(): string {
    const source = this.#state.authoring
    return JSON.stringify([this.authoringCommentDraftRevision(), source?.identity && source.member?.id ? this.#componentPropertyDrafts.revision(source.identity, source.member.id) : 0])
  }
  subscribeAuthoringDraftChanges(listener: () => void): () => void {
    const comments = this.#commentDrafts.subscribe(listener), properties = this.#componentPropertyDrafts.subscribe(listener)
    return () => { comments(); properties() }
  }

  getState = (): WorkbenchState => this.#state

  subscribe = (listener: Listener): (() => void) => {
    this.#listeners.add(listener)
    return () => this.#listeners.delete(listener)
  }

  onRepairDiagnostics = (repairer: (diagnostics: readonly Diagnostic[]) => void | Promise<void>): (() => void) => this.#diagnostics.onRepair(repairer)

  requestRepairDiagnostics(): Promise<void> {
    this.#record('diagnostics.repair')
    return this.#diagnostics.requestRepair()
  }

  /** Settings changed without changing document content; open voice surfaces read fresh capabilities. */
  refreshVoiceServices(): void {
    this.stopAuditions()
    this.#set({ voiceServiceVersion: (this.#state.voiceServiceVersion ?? 0) + 1 })
  }

  /** The host's way to hand a request to the conversation's agent. */
  onAgentRequest = (asker: (request: AgentRequest) => Promise<AgentAnswer | void>): (() => void) => {
    this.#askers.add(asker)
    return () => this.#askers.delete(asker)
  }

  /** The host's way to put a request in the conversation's composer for the author to finish; nothing is sent. */
  onAgentDraft = (drafter: (draft: AgentDraft) => void | Promise<void>): (() => void) => {
    this.#drafters.add(drafter)
    return () => this.#drafters.delete(drafter)
  }

  #nextDocumentPosition(): string { return `${this.#positionSeed}:${++this.#positionSequence}` }

  #restoredDocumentPosition(source: DocumentSessionState): boolean {
    const removed = this.#removedDocumentPosition
    if (!removed) return false
    if (removed.scope !== this.#documentScope || removed.generation !== this.#documentSelectionGeneration || removed.token !== this.#state.documentPositionToken
      || removed.identity !== source.identity || this.#state.documentPositionFailure !== 'removed' || source.error) {
      this.#removedDocumentPosition = undefined
      return false
    }
    const section = source.content?.sections[removed.sectionId]
    return !source.loading && !source.dirty && !source.saving && !source.restored && !source.composing && !source.recovering
      && source.version === removed.version && source.fingerprint === removed.fingerprint && source.content?.id === removed.documentId
      && Boolean(section && source.content?.sectionOrder.includes(removed.sectionId)
        && (!removed.stepId || section.stepOrder.includes(removed.stepId) && source.content.steps[removed.stepId]?.sectionId === removed.sectionId))
  }

  #set(patch: Partial<WorkbenchState>): void {
    if (Object.hasOwn(patch, 'position') || Object.hasOwn(patch, 'documentPositionFailure')) this.#removedDocumentPosition = undefined
    if (Object.hasOwn(patch, 'position')) { this.#cancelOpeningDocument(); this.#stopDocumentReturnScroll?.() }
    this.#state = { ...this.#state, ...patch, ...Object.hasOwn(patch, 'position') ? { documentPositionToken: this.#nextDocumentPosition(), documentPositionIntent: patch.documentPositionIntent ?? 'seek' } : {} }
    if (Object.hasOwn(patch, 'position')) this.#saveDocumentReturnPoint()
    for (const listener of this.#listeners) listener()
  }

  get #messages() {
    return MESSAGES[this.#state.locale]
  }

  setLocale(locale: Locale): void {
    if (locale === this.#state.locale) return
    this.#set({ locale })
    this.#record('locale.set', { 'presmium.locale': locale })
  }

  setDark(isDark: boolean): void {
    if (isDark === this.#state.isDark) return
    this.#set({ isDark })
    this.#broadcast({ type: 'appearance', dark: isDark })
    this.#record('appearance.set', { 'presmium.dark': isDark })
  }

  async loadDocuments(): Promise<void> {
    try {
      this.#set({ documents: await this.client.readDocuments(), documentsFailure: undefined, isBundleDisabled: false })
    } catch (error) {
      this.#set({ documentsFailure: errorMessage(error), isBundleDisabled: error instanceof WorkspaceServerError && error.status === 404 })
    }
  }

  planDocumentOperation(operation: ProjectOperation): Promise<ProjectPlan> {
    return this.client.planProjectChange({ id: crypto.randomUUID(), operation })
  }

  operateDocument(operation: ProjectOperation, planned?: ProjectPlan): Promise<OperationAnswer> {
    const attributes = Object.fromEntries(Object.entries(operation).filter(([, value]) => typeof value === 'string').map(([key, value]) => [`presmium.op.${key}`, value as string]))
    return this.#timed(`document.${operation.op}`, attributes, async () => {
      const scope = this.#documentScope
      if (this.#state.document === operation.dir) await this.flushPendingEdits()
      const answer = await this.client.operateDocument(operation, planned)
      if ((operation.op === 'rename' || operation.op === 'delete') && scope === this.#documentScope && this.#state.document === operation.dir) this.closeDocument()
      await this.loadDocuments()
      return answer
    })
  }

  /** Makes a document called `title` from the template, in a directory of its own; answers the directory. */
  async createDocument(title: string, template?: string): Promise<string> {
    const taken = this.#state.documents?.documents.map(doc => doc.dir) ?? []
    const answer = await this.operateDocument({ op: 'create', dir: documentDirFor(title, taken), title, ...template ? { template } : {} })
    return answer.dir!
  }

  /** Copies a document; the copy is called `title`. */
  async copyDocument(dir: string, title: string): Promise<string> {
    const taken = this.#state.documents?.documents.map(doc => doc.dir) ?? []
    let to = `${dir}-copy`
    for (let n = 2; taken.includes(to); n++) to = `${dir}-copy-${n}`
    const answer = await this.operateDocument({ op: 'copy', dir, to, title })
    return answer.dir ?? to
  }

  /** Title changes use the same fixed project plan as directory management. */
  async retitleDocument(dir: string, title: string): Promise<void> {
    await this.operateDocument({ op: 'retitle', dir, title })
  }

  #watchOpeningInteraction(cancel: () => void): () => void {
    if (typeof document === 'undefined') return () => {}
    const focused = document.activeElement, dialog = focused?.closest('[role="dialog"]')
    const interact = (event: Event) => {
      if (!event.isTrusted) return
      if (event.type === 'focusin' && (!(event.target instanceof Element) || event.target === focused || dialog?.contains(event.target))) return
      cancel()
    }
    const events = ['pointerdown', 'keydown', 'beforeinput', 'wheel', 'touchmove', 'focusin']
    for (const event of events) document.addEventListener(event, interact, true)
    return () => { for (const event of events) document.removeEventListener(event, interact, true) }
  }

  #cancelOpeningDocument(): void {
    const pending = this.#openingDocument
    this.#openingDocument = undefined
    pending?.stop()
  }

  #beginOpeningDocument(directory: string, focus?: 'body', fallback?: DocumentReturnPoint): void {
    this.#cancelOpeningDocument()
    this.#openingDocument = { directory, focus, fallback: fallback && { ...fallback }, scope: this.#documentScope, generation: this.#documentSelectionGeneration,
      stop: this.#watchOpeningInteraction(() => this.#cancelOpeningDocument()) }
  }

  #saveDocumentReturnPoint(): void {
    const source = this.#state.authoring, position = this.#state.position
    if (!source?.identity || !source.content || source.loading || !position || !source.content.sectionOrder.includes(position.section)) return
    const section = source.content.sections[position.section]
    let stepId = section?.stepOrder[position.step]
    if (this.#state.webView === 'present' || position.view === 'present') {
      const snapshot = this.#state.presentationSnapshot, ir = this.#state.ir
      if (!snapshot || !ir || source.dirty || source.saving || source.error || snapshot.version !== source.version || snapshot.fingerprint !== source.fingerprint
        || snapshot.project !== (source.projectRevision ?? 0) || snapshot.revision !== ir.revision || snapshot.revision !== this.#state.previewIrRevision) return
      const ids = ir.doc.sections.find(page => page.id === position.section)?.stepIds
      if (!ids || ids.length && !ids[position.step]) return
      stepId = ids[position.step]
      if (stepId && (!section?.stepOrder.includes(stepId) || source.content.steps[stepId]?.sectionId !== position.section)) return
    }
    if (!section || !stepId && section.stepOrder.length) return
    const viewport = this.#documentViewport, previous = this.#documentReturnPoints.read(source.identity)
    const measured = this.#state.webView === 'document' && viewport?.identity === source.identity ? documentViewportOffset(viewport.element, section.id) : undefined
    const offset = measured ?? (previous?.sectionId === section.id ? previous.offset : undefined)
    this.#documentReturnPoints.write(source.identity, { sectionId: section.id, ...stepId ? { stepId } : {}, ...offset !== undefined ? { offset } : {} })
  }

  #finishOpeningDocument(): void {
    const pending = this.#openingDocument, source = this.#state.authoring
    if (!pending || source?.loading || !source?.identity || !source.content) return
    if (source.error === 'document-identity-changed') { this.#cancelOpeningDocument(); return }
    if (pending.scope !== this.#documentScope || pending.directory !== this.#state.document || pending.generation !== this.#documentSelectionGeneration) { this.#cancelOpeningDocument(); return }
    if (!pending.basis) {
      pending.basis = { identity: source.identity, reset: source.reset, version: source.version, content: source.content }
      if (!pending.focus) {
        pending.point = this.#documentReturnPoints.read(source.identity) ?? pending.fallback
        if (!pending.point) {
          const section = source.content.sections[source.content.sectionOrder[0]!]
          if (!section) { this.#cancelOpeningDocument(); return }
          pending.point = { sectionId: section.id, ...section.stepOrder[0] ? { stepId: section.stepOrder[0] } : {} }
          pending.initial = true
        }
        if (documentReturnStep(source.content, pending.point) === undefined) {
          this.#documentReturnPoints.forget(source.identity); this.#cancelOpeningDocument()
          this.#set({ documentPositionFailure: 'removed' }); return
        }
      }
    }
    const basis = pending.basis
    if (source.identity !== basis.identity || source.reset !== basis.reset || source.version !== basis.version || source.content !== basis.content) { this.#cancelOpeningDocument(); return }
    const editor = this.#documentEditor, binding = this.#documentEditorBinding, viewport = this.#documentViewport
    if (!editor || editor.prosemirrorView.isDestroyed || binding?.identity !== source.identity || binding.reset !== source.reset || viewport?.identity !== source.identity) return
    // The editor can mount read-only while the source is still loading; a read-only body cannot take the caret yet.
    if (pending.focus && !editor.isEditable) return
    this.#cancelOpeningDocument()
    if (pending.focus) {
      const body = (blocks: typeof editor.document, sectionId?: string): { nodeId: string, sectionId: string } | undefined => {
        for (const block of blocks) {
          if (block.type === 'presmiumPage') sectionId = block.props.sectionId
          else if (sectionId && editor.schema.blockSchema[block.type].content === 'inline') return { nodeId: block.id, sectionId }
          const nested = body(block.children, sectionId)
          if (nested) return nested
        }
      }
      const target = body(editor.document)
      if (target && source.content.sections[target.sectionId]) {
        this.#set({ position: { section: target.sectionId, step: 0, trail: [], view: 'document' }, documentPositionFailure: undefined, documentNavigation: undefined })
        this.#focusDocumentNode(editor, target.nodeId)
      }
      return
    }
    const point = pending.point!, step = documentReturnStep(source.content, point)
    if (step === undefined) return
    this.#selectedSteps.set(`${pending.directory}\0${point.sectionId}`, step)
    this.#set({ position: { section: point.sectionId, step, trail: [], view: 'document' }, documentPositionFailure: undefined, documentNavigation: undefined })
    if (pending.initial) return
    const current = () => this.#state.document === pending.directory && this.#state.authoring?.identity === basis.identity
      && this.#state.authoring.reset === basis.reset && this.#state.authoring.version === basis.version && this.#state.authoring.content === basis.content
      && this.#documentViewport === viewport && this.#state.position?.section === point.sectionId && this.#state.position.step === step
    const restore = () => {
      if (!current()) { stop(); return }
      const top = viewport.element.scrollTop
      restoreDocumentViewport(viewport.element, point)
      if (viewport.element.scrollTop !== top) viewport.returnScrollTop = viewport.element.scrollTop
    }
    const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(restore)
    let stopInteraction: () => void = () => {}
    const stop = () => { observer?.disconnect(); stopInteraction(); viewport.returnScrollTop = undefined; if (this.#stopDocumentReturnScroll === stop) this.#stopDocumentReturnScroll = undefined }
    this.#stopDocumentReturnScroll?.(); this.#stopDocumentReturnScroll = stop
    stopInteraction = this.#watchOpeningInteraction(stop)
    observer?.observe(viewport.element)
    observer?.observe(editor.prosemirrorView.dom)
    restore()
  }

  /** Consume only this viewport owner's actual programmatic destination; user gestures retire it. */
  consumeDocumentReturnScroll(element: HTMLElement): boolean {
    const viewport = this.#documentViewport, top = viewport?.returnScrollTop
    if (viewport?.element !== element || top === undefined) return false
    viewport.returnScrollTop = undefined
    return element.scrollTop === top
  }

  attachDocumentViewport(element: HTMLElement, identity: string): () => void {
    this.#documentViewport?.stop()
    const scroll = () => { if (!this.#openingDocument) this.#saveDocumentReturnPoint() }
    const viewport = { element, identity, stop: () => element.removeEventListener('scroll', scroll) }
    this.#documentViewport = viewport
    element.addEventListener('scroll', scroll, { passive: true })
    this.#finishOpeningDocument()
    return () => { if (this.#documentViewport === viewport) { this.#saveDocumentReturnPoint(); this.#stopDocumentReturnScroll?.(); this.#documentViewport = undefined }; viewport.stop() }
  }

  /** The body editor became editable: an opening that waits to put the caret in it can finish now. */
  documentEditorEditable(): void { this.#finishOpeningDocument() }

  async openDocument(dir: string, options: { focus?: 'body', expectedIdentity?: string, fallbackReturnPoint?: DocumentReturnPoint } = {}): Promise<void> {
    if (this.#state.document === dir) {
      if (options.focus) { this.#beginOpeningDocument(dir, options.focus); this.#finishOpeningDocument() }
      return
    }
    this.#saveDocumentReturnPoint()
    this.#documentScope++
    const scope = this.#documentScope
    this.#stopSubscriptions()
    this.#history.subscribeReview(dir)
    this.#set({ ...DOCUMENT_RESET, document: dir, previewRenderer: 'web', webView: 'document' })
    this.#beginOpeningDocument(dir, options.focus, options.fallbackReturnPoint)
    this.#record('document.open')
    this.#diagnostics.subscribe(dir)
    // Authoring is ready from source JSON; compilation and preview are independent.
    const [opened] = await Promise.all([this.#documentSession.open(dir, options.expectedIdentity), this.#readFocus(dir), this.#history.read(dir)])
    if (opened === 'identity-changed' && scope === this.#documentScope && this.#state.document === dir) { this.closeDocument(); return }
    this.#finishOpeningDocument()
  }

  closeDocument(): void {
    this.#saveDocumentReturnPoint()
    this.#documentScope++
    if (this.#state.document !== undefined) this.#record('document.close')
    this.#stopSubscriptions()
    this.#documentSession.close()
    this.#set({ ...DOCUMENT_RESET, document: undefined })
    void this.loadDocuments()
  }

  async #readFocus(dir: string): Promise<void> {
    const version = this.#focusVersion
    try {
      const answer = await this.client.readFocus(dir)
      if (this.#state.document === dir && version === this.#focusVersion) this.#set({ focus: answer.focus ?? [] })
    } catch {
    }
  }

  async #readIr(dir: string): Promise<void> {
    const read = ++this.#irReads
    try {
      const ir = await this.client.readIr(dir)
      if (this.#state.document === dir && read === this.#irReads) {
        this.#set({ ir })
        this.#flashChanges()
      }
    } catch {
    }
  }

  /**
   * Makes the blocks the AI changed blink on the document, once the preview shows the revision the workbench has: the
   * author sees what changed where they read it, not as files.
   */
  #flashChanges(): void {
    const changes = this.#pendingFlash
    const ir = this.#state.ir
    if (!changes || !ir || this.#state.previewIrRevision !== ir.revision || this.#previewSinks.size === 0) return
    this.#pendingFlash = undefined
    const targets = changedPlaces(ir.doc, changes)
    if (targets.length === 0) return
    this.#trail++
    this.#broadcast({ type: 'flash', targets })
    this.#record('ai.change.shown', { 'presmium.places': targets.map(target => [target.section, target.block].filter(Boolean).join(':')).join('\n') })
  }

  retryDiagnostics(): void {
    this.#record('diagnostics.retry')
    this.#diagnostics.retry()
  }

  reportPreviewDiagnostics(report: PreviewDiagnostics): Promise<void> {
    return this.#diagnostics.reportPreview(report)
  }

  async #readPreview(dir: string): Promise<void> {
    const read = ++this.#previewReads
    const renderer = this.#state.previewRenderer
    const current = () => this.#state.document === dir && this.#state.previewRenderer === renderer && read === this.#previewReads
    try {
      const preview = await readPreviewWhenReady(this.client, dir, renderer, { waiting: current })
      if (current()) this.#set({ preview, previewFailure: undefined })
    } catch (error) {
      if (current()) this.#set({ previewFailure: error instanceof Error && error.name === 'TimeoutError' ? 'preview-timeout' : errorMessage(error) })
    }
  }

  async #handleIrEvent(dir: string, event: IrEvent): Promise<void> {
    if (this.#state.document !== dir) return
    const renderers = this.#state.ir?.doc.config.renderers
    if (event.revision !== this.#state.ir?.revision) {
      await this.#readIr(dir)
      // Opening the document and its initial SSE snapshot share one request.
      // A save can arrive while that request still describes the previous revision.
      if (this.#state.document === dir && event.revision !== this.#state.ir?.revision) await this.#readIr(dir)
    }
    if (this.#state.document !== dir) return
    // The view switch goes with the slides: a document that lost them shows its web form.
    if (this.#state.previewRenderer === 'slides' && !hasSlides(this.#state.ir)) this.setPreviewRenderer('web')
    else if (renderers && JSON.stringify(renderers) !== JSON.stringify(this.#state.ir?.doc.config.renderers)) void this.#readPreview(dir)
    if (event.files.length > 0) void this.#history.read(dir)
  }

  setMode(mode: WorkbenchMode): void {
    if (mode === this.#state.mode) return
    this.#set({ mode })
    this.#record('mode.set')
    this.point(undefined)
    this.#broadcast({ type: 'pick', on: false })
  }

  setPreviewRenderer(renderer: PreviewRenderer): void {
    if (renderer === this.#state.previewRenderer) return
    this.#set({ previewRenderer: renderer, preview: undefined, previewFailure: undefined, previewIrRevision: undefined, pointer: undefined })
    this.#record('view.set')
    this.#syncRuntimeStreams()
    const document = this.#state.document
    if (document !== undefined) void this.#readPreview(document)
  }

  /** Only public modal semantics are inspected; no field values or editor selection are read. */
  #previewModalOpen(): boolean {
    return typeof document !== 'undefined' && [...document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]')]
      .some(element => !element.closest('[hidden], [inert]') && element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden')
  }

  #canPreparePresentation(owned = false): boolean {
    const state = this.#state, source = state.authoring
    return Boolean(state.document !== undefined && source?.identity && source.version && source.fingerprint && source.content
      && !source.loading && !source.dirty && !source.saving && !source.restored && !source.error && !source.composing && !source.recovering
      && !state.hasDocumentTimingDrafts && !state.documentTimingDraftStorageFailure && !state.isCreatingPage && !state.isEditingPage
      && !state.isHistoryBusy && !state.isHistoryPreparing && !state.jobPreparing && !state.isBundleDisabled && !isAgentChanging(state)
      && (owned || !state.isPreparingPresentation) && !this.#previewModalOpen())
  }

  /** One explicit preparation owner; caller cancellation stops adoption without changing the current mode. */
  async preparePresentation(signal?: AbortSignal, deadline?: number): Promise<boolean> {
    const directory = this.#state.document, source = this.#state.authoring
    if (!this.#canPreparePresentation() || directory === undefined || !source?.identity || !source.version || !source.fingerprint || !source.content) {
      this.#set({ presentationFailure: this.#state.hasDocumentTimingDrafts ? this.#messages.timingDraftPending : this.#messages.presentationPending })
      return false
    }
    signal?.throwIfAborted()
    this.#presentationController?.abort(new Error('presentation-superseded'))
    const request = ++this.#presentationPreparation, scope = this.#documentScope
    const controller = this.#presentationController = new AbortController()
    const cancel = () => controller.abort(signal?.reason)
    signal?.addEventListener('abort', cancel, { once: true })
    const { identity, version, fingerprint } = source, project = source.projectRevision ?? 0
    const current = () => request === this.#presentationPreparation && scope === this.#documentScope && this.#state.document === directory && this.#state.authoring?.identity === identity
    const unchanged = () => {
      const latest = this.#state.authoring
      return current() && !controller.signal.aborted && (deadline === undefined || Date.now() < deadline) && this.#canPreparePresentation(true)
        && latest?.version === version && latest.fingerprint === fingerprint && (latest.projectRevision ?? 0) === project
    }
    this.#set({ isPreparingPresentation: true, presentationFailure: undefined })
    const timeout = setTimeout(() => controller.abort(deadline === undefined ? new DOMException('Presentation preparation timed out', 'TimeoutError') : new Error('client-request-expired')), Math.max(0, Math.min(this.#preparationTimeoutMs, deadline === undefined ? this.#preparationTimeoutMs : deadline - Date.now())))
    const stop = this.subscribe(() => { if (!unchanged()) controller.abort(new Error('presentation-source-changed')) })
    const input = () => controller.abort(new Error('client-preview-input'))
    const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') controller.abort(new Error('client-preview-cancelled')) }
    const observer = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(() => { if (this.#previewModalOpen()) controller.abort(new Error('client-preview-modal-open')) })
    if (typeof document !== 'undefined') {
      document.addEventListener('beforeinput', input, true); document.addEventListener('compositionstart', input, true); document.addEventListener('keydown', escape, true)
      observer?.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-modal', 'hidden', 'inert'] })
    }
    try {
      await waitForPreviewRequest(this.pausePlayback(), controller.signal)
      if (!unchanged()) return false
      controller.signal.throwIfAborted()
      const snapshot = await compileDocumentPreview(this.client, { directory, version, fingerprint, project }, controller.signal)
      controller.signal.throwIfAborted()
      const ir = snapshot.ir
      if (!current()) return false
      const entry = directory ? `${directory}/lecture.json` : 'lecture.json'
      if (!unchanged() || ir.revision !== snapshot.revision || ir.sourceFingerprints?.[entry] !== fingerprint
        || ir.doc.source?.version !== version || ir.doc.source.fingerprint !== fingerprint) throw new Error('presentation-source-changed')
      if (ir.diagnostics.some(finding => finding.severity === 'error')) throw new Error('document-preview-compile-failed')
      this.#set({ ir, preview: snapshot.preview, previewRenderer: 'web', previewFailure: undefined, presentationSnapshot: { version, fingerprint, revision: ir.revision, project } })
      return true
    } catch (error) {
      // Editing supersedes this snapshot; the existing stale status owns its recovery action.
      const cancelled = controller.signal.aborted && !(controller.signal.reason instanceof Error && controller.signal.reason.name === 'TimeoutError')
      if (current()) this.#set({ presentationFailure: cancelled || error instanceof Error && error.message === 'presentation-source-changed' ? undefined : this.#messages.presentationCompileFailed })
      return false
    } finally {
      clearTimeout(timeout); stop(); signal?.removeEventListener('abort', cancel); observer?.disconnect()
      if (typeof document !== 'undefined') { document.removeEventListener('beforeinput', input, true); document.removeEventListener('compositionstart', input, true); document.removeEventListener('keydown', escape, true) }
      if (this.#presentationController === controller) this.#presentationController = undefined
      if (current()) this.#set({ isPreparingPresentation: false })
    }
  }

  /** Closing an explicit preview cancels its pending adoption, while source saves retain their owner. */
  cancelPreviewPreparation(): void {
    if (!this.#state.isPreparingPresentation) return
    this.#presentationController?.abort(new Error('client-preview-cancelled'))
    this.#presentationPreparation++
    this.#set({ isPreparingPresentation: false, presentationFailure: undefined })
  }

  /**
   * Shows the web form as a page or as its presentation. The page is the same (and stays loaded): the view changes there,
   * at the place the author is; from the slides, the web form is shown first.
   */
  setWebView(view: WebView): void {
    if (view === this.#state.webView && this.#state.previewRenderer === 'web') return
    this.#awaitingView = view
    this.#set({ webView: view })
    this.#syncRuntimeStreams()
    this.#record('view.set', { 'presmium.preview.view': view })
    if (this.#state.previewRenderer !== 'web') {
      this.setPreviewRenderer('web')
    } else { if (view === 'present' && this.#state.document !== undefined) void this.#readPreview(this.#state.document); this.#showWebView() }
  }

  /** Source editing never owns IR or job streams. Export and presentation share one optional runtime stream. */
  #syncRuntimeStreams(): void {
    const document = this.#state.document
    const jobs = document !== undefined && this.#jobObservers.size > 0
    const runtime = document !== undefined && !jobs && (this.#previewObservers.size > 0 || this.#state.webView === 'present' || this.#state.previewRenderer === 'slides')
    if (!runtime || this.#runtimeDocument !== document) {
      this.#unsubscribeIr?.(); this.#unsubscribeIr = undefined; this.#runtimeDocument = undefined
    }
    if (!jobs || this.#jobsDocument !== document) { this.#jobs.stop(); this.#jobsDocument = undefined }
    if (jobs && this.#jobsDocument !== document) { this.#jobsDocument = document; this.#jobs.subscribe(document!) }
    if (runtime && !this.#unsubscribeIr) {
      this.#runtimeDocument = document
      this.#unsubscribeIr = this.client.subscribeIr(document!, event => void this.#handleIrEvent(document!, event), () => void this.#readIr(document!))
      void this.#readIr(document!)
    }
  }

  /** A visible reader or presentation shares the existing optional IR stream. */
  observePreviewRuntime(): () => void {
    const token = Symbol('preview')
    this.#previewObservers.add(token)
    this.#syncRuntimeStreams()
    return () => { if (this.#previewObservers.delete(token)) this.#syncRuntimeStreams() }
  }

  /** Follows export status only while a consumer is visible; it displaces the optional IR stream. */
  observeJobs(): () => void {
    const token = Symbol('exports')
    this.#jobObservers.add(token)
    this.#syncRuntimeStreams()
    if (this.#state.document !== undefined) void this.#readIr(this.#state.document)
    return () => { if (this.#jobObservers.delete(token)) this.#syncRuntimeStreams() }
  }

  /** Space and the left and right arrows pressed in a preview that the workbench plays (Video Production's). */
  observePlaybackKeys(listener: (command: PlaybackKey) => void): () => void {
    this.#playbackKeys.add(listener)
    return () => { this.#playbackKeys.delete(listener) }
  }

  /** Video and its dialogs borrow one owner; its idle reads never prepare a snapshot or stop media. */
  observeVoiceoverJobs(): () => void {
    return this.#state.document === undefined ? () => {} : this.#voiceover.observe(this.#state.document)
  }

  rememberVoiceoverJob(document: string, job: VoiceoverJob): void { this.#voiceover.remember(document, job) }

  #voiceoverPlaybackCurrent(job: VoiceoverJob): boolean {
    const state = this.#state, snapshot = state.presentationSnapshot, binding = state.productionPlan?.binding
    return Boolean(binding && this.#productionSourceCurrent(binding) && sameSource(binding.source, job.source) && binding.irRevision === job.revision
      && snapshot?.version === job.source.version && snapshot.fingerprint === job.source.fingerprint && snapshot.revision === job.revision
      && snapshot.project === (state.authoring?.projectRevision ?? 0) && state.previewIrRevision === job.revision
      && state.webView === 'present' && state.previewRenderer === 'web' && state.preview?.web && !state.isPreparingPresentation
      && !state.hasDocumentTimingDrafts && !state.documentTimingDraftStorageFailure && !documentRecordingActive(this))
  }

  async #refreshVoiceover(job: VoiceoverJob, signal: AbortSignal): Promise<boolean> {
    if (!this.#voiceoverPlaybackCurrent(job)) return false
    const document = this.#state.document!, scope = this.#documentScope, identity = this.#state.authoring?.identity, initialBinding = this.#state.productionPlan!.binding
    // This is the existing cache-only plan read: no compile, discovery, generation or draft flush.
    const plan = await this.client.readProductionPlan(document, { source: job.source, irRevision: job.revision }, signal)
    signal.throwIfAborted()
    const current = () => scope === this.#documentScope && identity === this.#state.authoring?.identity && this.#voiceoverPlaybackCurrent(job)
      && Boolean(this.#state.productionPlan && sameProductionBinding(initialBinding, this.#state.productionPlan.binding))
    if (!current()) return false
    if (!sameSource(plan.binding.source, job.source) || plan.binding.irRevision !== job.revision) throw new Error('voice-context-changed')
    if (sameProductionBinding(plan.binding, this.#state.productionPlan!.binding)) return true
    this.#playback.stopPreviewOutputs()
    await this.pausePlayback()
    signal.throwIfAborted()
    if (!current()) return false
    this.#production.reset()
    this.#set({ productionPlan: plan, voiceoverCacheRefresh: { source: job.source, revision: job.revision, sequence: (this.#state.voiceoverCacheRefresh?.sequence ?? 0) + 1 } })
    // The mounted production loader fetches this new binding in the existing frame.
    // Replacing the iframe for every clip would interrupt progressive playback and its connection.
    return true
  }

  #showWebView(sinks: Iterable<PreviewSink> = this.#previewSinks): void {
    const selected = this.#state.position
    const section = selected?.section ?? this.#state.ir?.doc.sections[0]?.id ?? this.#state.authoring?.content?.sectionOrder[0]
    if (section === undefined) return
    // A fresh or reloaded frame reports its initial document view before applying this command.
    this.#awaitingView = this.#state.webView
    for (const sink of sinks) sink.send({ type: 'go', section, step: selected?.step ?? 0, view: this.#state.webView })
  }

  reloadPreview(): void {
    this.#record('preview.reload')
    this.#reloadPreview()
  }

  /** New exact audio caches can refresh an existing current preview, without compiling or starting playback. */
  refreshVoicePlayback(source: CanonicalSource, revision: string): boolean {
    const state = this.#state, authoring = state.authoring, snapshot = state.presentationSnapshot
    if (!authoring || authoring.content?.id !== source.documentId || authoring.version !== source.version || authoring.fingerprint !== source.fingerprint
      || authoring.loading || authoring.dirty || authoring.saving || authoring.composing || authoring.restored || authoring.error
      || !snapshot || snapshot.version !== source.version || snapshot.fingerprint !== source.fingerprint || snapshot.revision !== revision
      || snapshot.project !== (authoring.projectRevision ?? 0) || state.webView !== 'present' || state.previewRenderer !== 'web' || !state.preview?.web) return false
    this.#reloadPreview()
    return true
  }

  #productionSourceCurrent(binding: ProductionBinding): boolean {
    const source = this.#state.authoring
    return Boolean(source?.content?.id === binding.source.documentId && source.version === binding.source.version && source.fingerprint === binding.source.fingerprint
      && !source.loading && !source.dirty && !source.saving && !source.composing && !source.restored && !source.error && !isAgentChanging(this.#state))
  }

  #productionReady(): boolean {
    const state = this.#state, binding = state.productionPlan?.binding, snapshot = state.presentationSnapshot
    return Boolean(binding && this.#productionSourceCurrent(binding) && snapshot?.revision === binding.irRevision
      && snapshot.version === binding.source.version && snapshot.fingerprint === binding.source.fingerprint && snapshot.project === (state.authoring?.projectRevision ?? 0)
      && state.previewIrRevision === binding.irRevision && state.webView === 'present' && state.previewRenderer === 'web'
      && !state.hasDocumentTimingDrafts && !state.documentTimingDraftStorageFailure)
  }

  /** Bounded observations, without reading or publishing any unsubmitted editor content. */
  authoringClientSnapshot(revision: number): AuthoringClientSnapshot | undefined {
    const state = this.#state, source = state.authoring, content = source?.content
    if (state.document === undefined || !content || !source?.identity || !source.version || !source.fingerprint) return undefined
    const locked = isAgentChanging(state), conflicted = Boolean(source.error || source.restored || source.recovering)
    const editing = { dirty: source.dirty, saving: source.saving, composing: source.composing, conflicted, locked, loading: source.loading }
    const safe = !source.loading && !source.composing && !conflicted && !locked && !state.isCreatingPage && !state.isEditingPage
    const clean = safe && !source.dirty && !source.saving && !state.hasDocumentTimingDrafts
    const plan = state.productionPlan, playback = state.productionPlayback
    const ready = clean && plan?.canPreview && this.#productionReady() && playback?.status === 'ready' && sameProductionBinding(plan.binding, playback.binding)
    const position = state.position, page = position && content.sections[position.section]
    const commentDrafts = source.member?.id ? this.#commentDrafts.peek(source.identity, source.member.id) : undefined
    const prepared = state.presentationSnapshot, actual = { documentId: content.id, version: source.version, fingerprint: source.fingerprint }
    const preparedCurrent = clean && prepared && prepared.version === source.version && prepared.fingerprint === source.fingerprint && prepared.project === (source.projectRevision ?? 0)
      && state.ir?.revision === prepared.revision && state.ir.doc.source?.version === source.version && state.ir.doc.source.fingerprint === source.fingerprint
    return { revision, source: { documentId: content.id, version: source.version, fingerprint: source.fingerprint }, view: state.webView, editing,
      interaction: state.documentPositionToken,
      ...page && position ? { position: { sectionId: page.id, ...page.stepOrder[position.step] ? { stepId: page.stepOrder[position.step] } : {} } } : {},
      ...plan ? { binding: plan.binding } : {}, ...playback ? { playback } : {},
      preview: preparedCurrent ? { status: 'prepared', source: actual, irRevision: prepared.revision, projectRevision: prepared.project }
        : { status: state.isPreparingPresentation ? 'preparing' : state.presentationFailure ? 'unavailable' : 'unprepared' },
      actions: ['state.read', 'playback.pause', ...safe ? ['source.flush' as const] : [],
        ...commentDrafts?.readable ? ['comment-drafts.inspect' as const, 'comment-drafts.read' as const] : [],
        ...clean && !state.isHistoryBusy && !state.isHistoryPreparing && commentDrafts?.readable && commentDrafts.ownersKnown ? ['comment-drafts.discard' as const, ...state.webView === 'document' && commentDrafts.hasRestoreComposer() ? ['comment-drafts.restore' as const] : []] : [],
        ...source.member?.id && this.#componentPropertyDrafts.readable(source.identity, source.member.id) ? ['component-drafts.inspect' as const, 'component-drafts.read' as const] : [],
        ...clean && !state.isHistoryBusy && !state.isHistoryPreparing && source.member?.id && this.#componentPropertyDrafts.readable(source.identity, source.member.id) ? ['component-drafts.restore' as const, 'component-drafts.discard' as const] : [],
        ...this.#canPreparePresentation() ? ['preview.prepare' as const] : [],
        ...clean && (state.webView === 'document' || ready) ? ['position.step' as const] : [],
        ...ready ? ['playback.play' as const, 'playback.rate' as const, ...plan?.timing === 'confirmed' ? ['playback.seek' as const] : []] : []], unsubmittedContent: 'not-inspected' }
  }

  /** Reuses the same source actor, mutual-audio gate and frame acknowledgements as the human controls. */
  async executeAuthoringClientAction(action: AuthoringClientAction, expected: CanonicalSource, signal: AbortSignal, deadline?: number, fits?: (result: AuthoringClientActionResult) => boolean): Promise<void | AuthoringClientActionResult> {
    const scope = this.#documentScope, identity = this.#state.authoring?.identity, document = this.#state.document, interaction = this.#state.documentPositionToken
    const owned = () => { signal.throwIfAborted(); if (scope !== this.#documentScope || identity !== this.#state.authoring?.identity || document !== this.#state.document) throw new Error('client-document-changed') }
    const current = () => { owned(); const state = this.authoringClientSnapshot(0); if (!state || !sameSource(state.source, expected) || !state.actions.includes(action.type)) throw new Error('client-action-unavailable'); return state }
    current()
    if (action.type === 'state.read') return
    if (action.type === 'comment-drafts.inspect' || action.type === 'comment-drafts.read') {
      const drafts = identity ? this.documentCommentDrafts(identity) : undefined
      if (!drafts?.readable || !fits) throw new Error('client-comment-drafts-unavailable')
      const result = await inspectCommentDrafts(drafts, action, signal, fits)
      current()
      return result
    }
    if (action.type === 'comment-drafts.restore' || action.type === 'comment-drafts.discard') {
      const memberId = this.#state.authoring?.member?.id, drafts = identity ? this.documentCommentDrafts(identity) : undefined
      if (!identity || !memberId || !drafts || !fits) throw new Error('client-comment-drafts-unavailable')
      const guard = () => {
        owned()
        const state = this.#state, source = state.authoring
        if (!source?.content || source.member?.id !== memberId || !source.version || !source.fingerprint
          || !sameSource({ documentId: source.content.id, version: source.version, fingerprint: source.fingerprint }, expected)
          || source.loading || source.dirty || source.saving || source.composing || source.restored || source.recovering || source.error || isAgentChanging(state)
          || state.isCreatingPage || state.isEditingPage || state.isHistoryBusy || state.isHistoryPreparing || state.hasDocumentTimingDrafts
          || action.type === 'comment-drafts.restore' && state.webView !== 'document') throw new Error('client-source-changed')
      }
      return mutateCommentDrafts(drafts, action, signal, guard, fits)
    }
    if (action.type === 'component-drafts.inspect' || action.type === 'component-drafts.read') {
      const memberId = this.#state.authoring?.member?.id
      if (!identity || !memberId || !fits || !this.#componentPropertyDrafts.readable(identity, memberId)) throw new Error('client-component-drafts-unavailable')
      const result = await inspectComponentDrafts(this.#componentPropertyDrafts, { identity, memberId, source: expected }, action, signal, fits)
      current()
      return result
    }
    if (action.type === 'component-drafts.restore' || action.type === 'component-drafts.discard') {
      const memberId = this.#state.authoring?.member?.id
      if (!identity || !memberId || !fits) throw new Error('client-component-drafts-unavailable')
      const guard = (draft?: ComponentPropertyDraft) => {
        owned()
        const state = this.#state, source = state.authoring
        if (!source?.content || source.identity !== identity || source.member?.id !== memberId || !source.version || !source.fingerprint
          || !sameSource({ documentId: source.content.id, version: source.version, fingerprint: source.fingerprint }, expected)
          || source.loading || source.dirty || source.saving || source.composing || source.restored || source.recovering || source.error || isAgentChanging(state)
          || state.isCreatingPage || state.isEditingPage || state.isHistoryBusy || state.isHistoryPreparing || state.hasDocumentTimingDrafts) throw new Error('client-source-changed')
        if (action.type === 'component-drafts.restore') {
          const block = source.content.blocks[action.draft.blockId]
          if (block?.type !== 'component' || draft && block.definitionId !== draft.definitionId) throw new Error('client-component-target-unavailable')
          // The source-bound bank checks the saved Definition again before adopting its strict fork.
          try { effectiveComponentParameters(source.content, block.id, action.draft.stepId) } catch { throw new Error('client-component-target-unavailable') }
        }
      }
      return mutateComponentDrafts(this.#componentPropertyDrafts, { identity, memberId, source: expected }, action, signal, guard, fits)
    }
    if (action.type === 'preview.prepare') {
      const view = this.#state.webView
      const stop = this.subscribe(() => { if (interaction !== this.#state.documentPositionToken || view !== this.#state.webView) this.cancelPreviewPreparation() })
      try {
        const prepared = await this.preparePresentation(signal, deadline)
        owned(); signal.throwIfAborted()
        const result = this.authoringClientSnapshot(0)
        if (!prepared || result?.preview?.status !== 'prepared' || !result.preview.source || !sameSource(result.preview.source, expected)
          || result.view !== view || result.interaction !== interaction) throw new Error('client-preview-unavailable')
      } finally { stop() }
      return
    }
    if (action.type === 'source.flush') {
      await this.flushPendingEdits(signal); owned()
      const source = this.#state.authoring
      if (!source || source.dirty || source.saving || source.composing || source.error || source.restored) throw new Error('client-source-not-saved')
      return
    }
    if (action.type === 'playback.pause') { this.#interruptProductionNavigation(); await this.pausePlayback(); owned(); return }
    if (action.type === 'position.step') {
      const content = this.#state.authoring?.content, section = content?.sections[action.sectionId]
      if (!section?.stepOrder.includes(action.stepId) || content?.steps[action.stepId]?.sectionId !== action.sectionId) throw new Error('client-step-not-found')
      await this.pausePlayback(); current()
      if (interaction !== this.#state.documentPositionToken) throw new Error('client-position-changed')
      const present = this.#state.webView === 'present'
      const result = await this.selectDocumentStep(action.sectionId, action.stepId)
      owned()
      const state = this.authoringClientSnapshot(0)
      if (!state || !sameSource(state.source, expected) || state.position?.sectionId !== action.sectionId || state.position.stepId !== action.stepId
        || present && (!result || result.status !== 'ready' || result.position?.sectionId !== action.sectionId || result.position.stepId !== action.stepId || result.position.playing)) throw new Error('client-step-unverified')
      return
    }
    const plan = this.#state.productionPlan
    if (!plan || !sameProductionBinding(plan.binding, action.binding)) throw new Error('client-playback-binding-changed')
    if (action.type === 'playback.seek' && (plan.timing !== 'confirmed' || plan.durationMs === undefined || action.timeMs > plan.durationMs)) throw new Error('client-seek-out-of-range')
    if (action.type === 'playback.rate' && !(PRODUCTION_PLAYBACK_RATES as readonly number[]).includes(action.rate)) throw new Error('client-rate-unavailable')
    const control: ProductionPlaybackCommand = action.type === 'playback.play' ? { action: 'play', binding: action.binding }
      : action.type === 'playback.seek' ? { action: 'seek', binding: action.binding, timeMs: action.timeMs } : { action: 'rate', binding: action.binding, rate: action.rate }
    const result = await this.controlProduction(control, signal); owned()
    if (result.status !== 'ready' || !result.position || action.type === 'playback.play' && !result.position.playing
      || action.type === 'playback.rate' && result.position.rate !== action.rate) throw new Error('client-playback-unverified')
  }

  /** Reads the actual derived plan without building the visuals or synthesizing speech. */
  async prepareProductionPlan(signal: AbortSignal): Promise<ProductionPlan> {
    const initial = this.#state, source = initial.authoring, identity = source?.identity, directory = initial.document, scope = this.#documentScope
    if (!identity || directory === undefined || !source?.version || !source.fingerprint || source.loading || source.dirty || source.saving || source.composing || source.restored || source.error) throw new Error('production-source-changed')
    const snapshot = await readDocumentSnapshot(this.client, { directory, version: source.version, fingerprint: source.fingerprint }, signal)
    signal.throwIfAborted()
    if (!snapshot.doc.source || scope !== this.#documentScope || this.#state.authoring?.identity !== identity) throw new Error('production-source-changed')
    const plan = await this.client.readProductionPlan(directory, { source: snapshot.doc.source, irRevision: snapshot.revision }, signal)
    signal.throwIfAborted()
    if (scope !== this.#documentScope || this.#state.authoring?.identity !== identity || !this.#productionSourceCurrent(plan.binding)) throw new Error('production-source-changed')
    this.#set({ productionPlan: plan })
    if (this.#production.changed()) void this.pausePlayback().catch(() => {})
    return plan
  }

  #interruptProductionNavigation(): void {
    this.#set({ productionNavigationVersion: this.#state.productionNavigationVersion + 1 })
  }

  /** The child confirms seeks and playback; the parent never estimates a playhead using a timer. */
  async controlProduction(control: ProductionPlaybackCommand, signal?: AbortSignal): Promise<ProductionPlaybackState> {
    if (control.action === 'pause' || control.action === 'seek' || control.action === 'step' && !control.utteranceId) this.#interruptProductionNavigation()
    if (control.action === 'seek' && this.#state.productionPlan?.timing !== 'confirmed') throw new Error('production-plan-unavailable')
    const scope = this.#documentScope
    this.#set({ productionFailure: undefined })
    try {
      if (control.action === 'play' || control.action === 'load' || control.action === 'seek' || control.action === 'step') {
        if (control.action === 'load') this.#playback.stopPreviewOutputs()
        else this.stopAuditions()
        await this.pausePlayback()
        signal?.throwIfAborted()
      }
      return await this.#production.control(control, signal)
    } catch (error) {
      const code = errorMessage(error), current = this.#state.productionPlan?.binding
      if (scope === this.#documentScope && !signal?.aborted && current && sameProductionBinding(current, control.binding) && this.#productionReady()
        && !(control.action === 'load' && code === 'production-playback-cancelled')
        && !['production-source-changed', 'production-request-superseded', 'production-request-cancelled'].includes(code)) this.#set({ productionFailure: code })
      throw error
    }
  }

  closeProduction(): void {
    this.#interruptProductionNavigation()
    this.#production.reset()
    this.#set({ productionPlan: undefined, productionFailure: undefined })
  }

  /** Explicitly enables the standard production capability in this document's one authoritative source. */
  async enableDocumentProduction(): Promise<void> {
    const scope = this.#documentScope, identity = this.#state.authoring?.identity
    if (!identity) throw new Error('production-source-changed')
    await this.pausePlayback()
    await this.flushPendingEdits()
    const source = this.#state.authoring, document = this.getDocument(identity)
    if (scope !== this.#documentScope || !document || !source?.version || !source.fingerprint || source.dirty || source.saving || source.composing || source.restored || source.error || isAgentChanging(this.#state)) throw new Error('production-source-changed')
    const plugins = [...new Set([...(document.metadata.plugins ?? []), '@lincyaw/addon-lecture', '@lincyaw/addon-speech'])]
    const next = { ...document, metadata: { ...document.metadata, plugins, renderers: { ...document.metadata.renderers, lecture: document.metadata.renderers?.lecture ?? {} } } }
    this.#documentSession.record(next)
    await this.flushPendingEdits()
    if (scope !== this.#documentScope || this.#state.authoring?.identity !== identity || this.#state.authoring.dirty || this.#state.authoring.saving || this.#state.authoring.error) throw new Error('production-source-changed')
  }

  #reloadPreview(): void {
    this.#set({ previewReload: this.#state.previewReload + 1 })
    const address = this.#state.previewRenderer === 'slides' ? this.#state.preview?.slides : this.#state.preview?.web
    if (!address) this.retryPreview()
  }

  retryPreview(): void {
    this.#record('preview.retry')
    if (this.#state.document !== undefined) void this.#readPreview(this.#state.document)
  }

  #pageEditMessage(reason: string): string {
    const t = this.#messages
    if (reason === 'native-unavailable') return t.nativeEditUnavailable
    if (reason.startsWith('native-')) return t.nativeEditDraft
    switch (reason) {
      case 'page-multiple-files': return t.pageEditFiles
      case 'page-boundary': return t.pageEditBoundary
      case 'page-hierarchy': return t.pageEditHierarchy
      case 'page-implicit-title': return t.pageEditImplicitTitle
      case 'page-formatted-title': return t.pageEditFormattedTitle
      case 'page-title': return t.pageEditInvalidTitle
      case 'page-compiler': return t.pageEditCompiler
      case 'page-referenced': return t.pageDeleteReferenced
      case 'page-external-bindings': return t.pageDeleteExternalBindings
      case 'page-references-unavailable': return t.pageDeleteReferencesUnavailable
      case 'page-busy': return t.pageEditBusy
      case 'preview-flush-unavailable': return t.pageEditPreview
      case 'page-context-changed': return t.pageEditChanged
      case 'save-timeout': return t.pageEditFlushTimeout
      case 'source-draft': return t.pageEditSourceDraft
      case 'source-refresh': return t.pageEditRefresh
      case 'page-draft':
      case 'source-conflict':
      case 'narration-conflict':
      case 'revision-conflict': return t.pageEditDraft
      case 'save-locked': return t.saveLocked
      default: return t.pageEditRequestFailed
    }
  }

  pageEditCapability(section: string): { rename: boolean, move: boolean, reason?: string } {
    const exists = Boolean(this.#state.authoring?.content?.sections[section])
    const capability = { rename: exists, move: exists }
    if (isAgentChanging(this.#state)) return { rename: false, move: false, reason: this.#messages.saveLocked }
    if (this.#state.isCreatingPage || this.#state.isEditingPage || this.#state.isHistoryBusy) return { rename: false, move: false, reason: this.#messages.pageEditBusy }
    return capability
  }

  clearPageEditFailure(): void { this.#set({ pageEditFailure: undefined }) }

  /** The preview acknowledges that all registered lecture playback has paused before audition audio starts. */
  pausePlayback(interactive = false): Promise<void> { if (interactive) this.#interruptProductionNavigation(); return this.#playback.pause() }

  /** The owner must invalidate pending audio responses as well as stop its current audio element. */
  registerAudition(stop: () => void): () => void { return this.#playback.registerAudition(stop) }
  registerAudioCapture(stop: () => void): () => void { this.#interruptProductionNavigation(); return this.#playback.registerAudioCapture(stop) }

  /** Inline runtimes join the same exclusive playback group as author auditions. */
  startDocumentPlayback(stop: () => Promise<void>): Promise<() => void> { this.#interruptProductionNavigation(); return this.#playback.startDocumentPlayback(stop) }

  stopAuditions(): void { this.#playback.stopAuditions() }

  /** The latest source actor value, scoped to the field/editor that captured its identity. */
  getDocument(identity?: string): TeachingDocument | undefined {
    const source = this.#state.authoring
    if (!source || source.loading || source.directory !== this.#state.document || identity !== undefined && identity !== source.identity) return undefined
    return source.content
  }
  /** Native image uploads commit the original file without interrupting later text input. */
  async importDocumentImage(file: File, identity: string, signal?: AbortSignal): Promise<string> {
    if (!this.getDocument(identity) || isAgentChanging(this.#state)) throw new Error('document-identity-changed')
    this.stopAuditions()
    await this.pausePlayback()
    await this.flushPendingEdits(signal)
    if (!this.getDocument(identity) || isAgentChanging(this.#state)) throw new Error('document-identity-changed')
    const answer = await this.#documentSession.importImage(file, identity, signal)
    signal?.throwIfAborted()
    const asset = this.getDocument(identity)?.assets[answer.assetId]
    if (!asset || asset.kind !== 'image' || asset.location !== answer.content.assets[answer.assetId]?.location) throw new Error('document-identity-changed')
    return asset.location
  }
  #mediaSource(expected: { identity: string, version: string }): DocumentSessionState {
    const source = this.#state.authoring
    if (!source || !this.getDocument(expected.identity) || source.version !== expected.version || !source.fingerprint
      || source.dirty || source.saving || source.composing || source.restored || source.error || isAgentChanging(this.#state)) throw new Error('production-source-changed')
    return source
  }
  /** Inspects the saved declaration, including a local asset not yet attached to a step. */
  async inspectDocumentAudio(assetId: string, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<DocumentAudioInspectionAnswer> {
    const source = this.#mediaSource(expected), scope = this.#documentScope
    const answer = await this.client.readDocumentAudio(source.directory, assetId, expected.version, signal)
    signal?.throwIfAborted()
    if (scope !== this.#documentScope || answer.document !== source.directory || answer.version !== expected.version || answer.fingerprint !== source.fingerprint || answer.inspection.assetId !== assetId) throw new Error('production-source-changed')
    this.#mediaSource(expected)
    return answer
  }
  /** Recognition keeps its captured source even if subsequent author input moves to a newer version. */
  async startDocumentTranscription(request: Omit<DocumentTranscriptionRequest, 'source'>, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<TranscriptionTask> {
    const source = this.#mediaSource(expected)
    if (!source.content || !source.fingerprint) throw new Error('production-source-changed')
    const sent: DocumentTranscriptionRequest = { ...structuredClone(request), source: { documentId: source.content.id, version: expected.version, fingerprint: source.fingerprint } }
    const answer = await this.client.startTranscription(source.directory, sent, signal)
    signal?.throwIfAborted()
    if (answer.document !== source.directory || answer.intent !== sent.intent || answer.source.documentId !== sent.source.documentId
      || answer.source.version !== sent.source.version || answer.source.fingerprint !== sent.source.fingerprint || answer.audio.assetId !== sent.assetId || answer.audio.sha256 !== sent.audioSha256) throw new Error('transcription-response-changed')
    return answer
  }
  async planDocumentTranscription(draft: TranscriptionAdoptionDraft, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<TranscriptionAdoptionPlan> {
    const source = this.#mediaSource(expected), sent = structuredClone(draft)
    if (sent.source.documentId !== source.content?.id || sent.source.version !== expected.version || sent.source.fingerprint !== source.fingerprint) throw new Error('production-source-changed')
    const answer = await this.client.planTranscriptionAdoption(source.directory, sent, signal)
    signal?.throwIfAborted()
    this.#mediaSource(expected)
    if (answer.document !== source.directory || answer.changeId !== sent.id || answer.source.documentId !== sent.source.documentId
      || answer.source.version !== sent.source.version || answer.source.fingerprint !== sent.source.fingerprint || answer.candidate.id !== sent.candidate.id
      || answer.candidate.revision !== sent.candidate.revision || answer.draftRevision !== sent.draftRevision) throw new Error('transcription-response-changed')
    return answer
  }
  async adoptDocumentTranscription(request: TranscriptionAdoptionApply, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<TranscriptionAdoptionAnswer> {
    const source = this.#mediaSource(expected), sent = structuredClone(request)
    if (sent.source.documentId !== source.content?.id || sent.source.version !== expected.version || sent.source.fingerprint !== source.fingerprint) throw new Error('production-source-changed')
    const answer = await this.#writeDocumentMedia(expected, directory => this.client.applyTranscriptionAdoption(directory, sent, signal), signal)
    if (answer.adoption.planId !== sent.planId || answer.adoption.candidate.id !== sent.candidate.id || answer.adoption.candidate.revision !== sent.candidate.revision
      || answer.adoption.draftRevision !== sent.draftRevision) throw new Error('transcription-response-changed')
    return answer
  }
  async #writeDocumentMedia<T extends DocumentChangeAnswer>(expected: { identity: string, version: string }, write: (directory: string) => Promise<T>, signal?: AbortSignal): Promise<T> {
    const scope = this.#documentScope, source = this.#mediaSource(expected)
    this.stopAuditions()
    await this.pausePlayback()
    await this.flushPendingEdits(signal)
    signal?.throwIfAborted()
    if (scope !== this.#documentScope) throw new Error('production-source-changed')
    this.#mediaSource(expected)
    let answer: T
    try { answer = await write(source.directory) }
    finally { await this.#documentSession.refresh(source.directory) }
    signal?.throwIfAborted()
    if (scope !== this.#documentScope || answer.document !== source.directory || answer.content.id !== source.content?.id) throw new Error('production-source-changed')
    this.#mediaSource({ identity: expected.identity, version: answer.version })
    return answer
  }
  /** Import commits the original file and its declaration together, without creating a clip or narration. */
  async importDocumentAudio(file: File, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<DocumentAudioImportAnswer> {
    let request: { identity: string, version: string, id: string } | undefined
    const answer = await this.#writeDocumentMedia(expected, async directory => {
      const existing = this.#audioImports.get(file), first = !existing || existing.identity !== expected.identity
      request = first ? { identity: expected.identity, version: expected.version, id: crypto.randomUUID() } : existing!
      this.#audioImports.set(file, request)
      try { return await this.client.importDocumentAudio(directory, file, { id: request.id, baseDocumentVersion: request.version }, signal) }
      catch (error) {
        if (first && error instanceof WorkspaceServerError && error.status >= 400 && error.status < 500 && this.#audioImports.get(file) === request) this.#audioImports.delete(file)
        throw error
      }
    }, signal)
    if (answer.changeId !== request?.id || answer.inspection.assetId !== answer.assetId || !answer.content.assets[answer.assetId]) throw new Error('production-source-changed')
    if (this.#audioImports.get(file) === request) this.#audioImports.delete(file)
    return answer
  }
  /** Reconcile an ambiguous save against real asset bytes before admitting a new source-bound import. */
  async ensureDocumentAudio(file: File, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<DocumentAudioInspectionAnswer> {
    const source = this.#mediaSource(expected)
    if (!file.size || file.size > DOCUMENT_AUDIO_MAX_BYTES) throw new Error('production.asset-size')
    let pending = this.#audioFingerprints.get(file)
    if (!pending) {
      pending = file.arrayBuffer().then(bytes => crypto.subtle.digest('SHA-256', bytes)).then(bytes => [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, '0')).join(''))
      this.#audioFingerprints.set(file, pending)
    }
    const hash = await pending
    signal?.throwIfAborted(); this.#mediaSource(expected)
    for (const asset of Object.values(source.content?.assets ?? {})) {
      if (asset.kind !== 'audio' || asset.fingerprint !== `sha256:${hash}`) continue
      try {
        const answer = await this.inspectDocumentAudio(asset.id, expected, signal)
        if (answer.inspection.sha256 === hash) return answer
      } catch (error) {
        if (!(error instanceof WorkspaceServerError) || ![404, 413, 422].includes(error.status)) throw error
      }
    }
    signal?.throwIfAborted(); this.#mediaSource(expected)
    const previous = this.#audioImports.get(file)
    if (previous?.identity === expected.identity && previous.version !== expected.version) this.#audioImports.delete(file)
    let imported: DocumentAudioImportAnswer
    try { imported = await this.importDocumentAudio(file, expected, signal) }
    catch (error) {
      if (!previous || this.#audioImports.get(file) !== previous || !(error instanceof WorkspaceServerError)
        || error.status !== 409 || error.message !== 'document-version-changed') throw error
      signal?.throwIfAborted(); this.#mediaSource(expected)
      this.#audioImports.delete(file)
      imported = await this.importDocumentAudio(file, expected, signal)
    }
    return { document: imported.document, version: imported.version, fingerprint: imported.fingerprint, inspection: imported.inspection }
  }
  /** The source actor owns this external transaction; capture is independent from preview output. */
  async adoptDocumentRecording(request: DocumentRecordingAdoption, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<VerifiedDocumentRecordingAdoption> {
    const scope = this.#documentScope, source = this.#mediaSource(expected), sent = structuredClone(request)
    if (sent.source.documentId !== source.content?.id || sent.source.version !== source.version || sent.source.fingerprint !== source.fingerprint
      || !source.content.steps[sent.stepId] || !source.content.assets[sent.assetId]) throw new Error('production-source-changed')
    this.#playback.stopPreviewOutputs()
    await this.pausePlayback()
    await this.flushPendingEdits(signal)
    signal?.throwIfAborted()
    if (scope !== this.#documentScope) throw new Error('production-source-changed')
    this.#mediaSource(expected)
    const answer = await this.#documentSession.commitExternal(expected, async (_before, ownedSignal) => {
      const saved = await this.client.adoptRecording(source.directory, sent, ownedSignal)
      if (saved.changeId !== sent.id || saved.verification.stepId !== sent.stepId || saved.verification.assetId !== sent.assetId || saved.verification.sha256 !== sent.sha256) throw new Error('document-confirmation')
      return saved
    }, (before, remote, current) => reconcileRecordingAdoption(sent, before, remote, current), signal)
    signal?.throwIfAborted()
    if (scope !== this.#documentScope || this.#state.authoring?.identity !== expected.identity) throw new Error('production-source-changed')
    return answer
  }
  async applyDocumentMediaClip(clip: MediaClip, expected: { identity: string, version: string, binding: ProductionBinding }, signal?: AbortSignal): Promise<DocumentChangeAnswer> {
    this.#mediaSource(expected)
    if (!this.#state.productionPlan || !sameProductionBinding(this.#state.productionPlan.binding, expected.binding) || !this.#productionSourceCurrent(expected.binding)) throw new Error('production-source-changed')
    const id = crypto.randomUUID(), value = structuredClone(clip)
    const answer = await this.#writeDocumentMedia(expected, directory => this.client.changeDocumentMediaClip(directory, { id, baseDocumentVersion: expected.version, binding: expected.binding, clip: value }, signal), signal)
    if (answer.changeId !== id) throw new Error('production-source-changed')
    return answer
  }
  async removeDocumentMediaClip(clipId: string, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<DocumentChangeAnswer> {
    const id = crypto.randomUUID()
    const answer = await this.#writeDocumentMedia(expected, directory => this.client.changeDocumentMediaClip(directory, { id, baseDocumentVersion: expected.version, removeClipId: clipId }, signal), signal)
    if (answer.changeId !== id) throw new Error('production-source-changed')
    return answer
  }
  /** Applies a reviewed voice scope to the current source actor, without replacing body or narration. */
  async applyDocumentVoice(scope: DocumentVoiceScope, value: VoiceSource | null, options: DocumentVoiceOptions, expected: { identity: string, version: string, fingerprint: string }): Promise<boolean> {
    const source = this.#state.authoring, document = this.getDocument(expected.identity), context = this.#documentScope
    if (!source || !document || source.version !== expected.version || source.fingerprint !== expected.fingerprint
      || source.dirty || source.saving || source.composing || source.restored || source.error || isAgentChanging(this.#state)) throw new Error('voice-context-changed')
    const next = changeDocumentVoice(document, scope, value, options)
    if (next === document) return true
    this.stopAuditions()
    this.#documentSession.record(next)
    await this.flushPendingEdits()
    if (context !== this.#documentScope || this.#state.authoring?.identity !== expected.identity) return false
    const saved = this.#state.authoring
    if (!saved || saved.dirty || saved.saving || saved.composing || saved.restored || saved.error) throw new Error('voice-save-unconfirmed')
    return true
  }
  /** Parameter planning is an authoring read; it does not compile, change modes or write the document. */
  async planDocumentComponentAction(action: DocumentComponentAction, id: string, expected: { identity: string, version: string, fingerprint: string }, signal?: AbortSignal): Promise<DocumentAuthoringActionPlan> {
    const source = this.#mediaSource(expected), scope = this.#documentScope
    if (!source.content || !source.fingerprint || source.fingerprint !== expected.fingerprint) throw new Error('component-source-changed')
    const request = { id, source: componentSource({ content: source.content, version: source.version!, fingerprint: source.fingerprint }), action: structuredClone(action) }
    const answer = await this.client.planDocumentAction(source.directory, request, signal)
    signal?.throwIfAborted()
    const current = this.#mediaSource(expected)
    if (scope !== this.#documentScope || !current.content || !current.fingerprint || answer.document !== source.directory || answer.changeId !== id || answer.effect.kind !== 'component'
      || !sameComponentSource(answer.source, request.source) || JSON.stringify(answer.action) !== JSON.stringify(request.action)) throw new Error('component-source-changed')
    return answer
  }
  /** The existing source actor owns adoption and ambiguous-response recovery, including later native input. */
  async applyDocumentComponentPlan(plan: DocumentAuthoringActionPlan, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<{ answer: DocumentChangeAnswer, commitId?: string }> {
    if (plan.effect.kind !== 'component' || !plan.action.type.startsWith('component.')) throw new Error('component-plan-invalid')
    const source = this.#mediaSource(expected), action = plan.action as DocumentComponentAction, scope = this.#documentScope
    if (!source.content || !source.fingerprint || !sameComponentSource(plan.source, componentSource({ content: source.content, version: source.version!, fingerprint: source.fingerprint }))) throw new Error('component-source-changed')
    this.#playback.stopPreviewOutputs()
    await this.pausePlayback()
    let receipt: AppliedDocumentAuthoringAction | undefined
    const remember = (answer: DocumentChangeAnswer) => {
      if (this.#documentEditorBinding?.identity === expected.identity) this.#documentEditorCommands?.rememberComponentConfiguration(answer.content)
      return { answer, ...receipt?.commit ? { commitId: receipt.commit.id } : {} }
    }
    try {
      const answer = await this.#documentSession.commitExternal(expected, async (_before, ownedSignal) => {
        receipt = await this.client.applyDocumentAction(source.directory, { planId: plan.planId, digest: plan.digest, source: plan.source }, ownedSignal)
        const current = await this.client.readDocument(source.directory)
        if (receipt.planId !== plan.planId || receipt.digest !== plan.digest || receipt.changeId !== plan.changeId || receipt.action !== plan.action.type
          || !receipt.verification.saved || !receipt.verification.actionStateVerified || !sameComponentSource(receipt.verification.source, componentSource(current))) throw new Error('document-confirmation')
        return { ...current, changeId: receipt.changeId, files: receipt.files }
      }, (before, answer, current) => reconcileComponentAction(action, before, answer, current), signal)
      if (scope !== this.#documentScope || this.#state.authoring?.identity !== expected.identity) throw new Error('component-source-changed')
      return remember(answer)
    } catch (error) {
      const current = this.#state.authoring, file = plan.files.find(value => value.path === (source.directory ? `${source.directory}/lecture.json` : 'lecture.json'))
      // commitExternal has independently read and reconciled this exact predicted canonical version.
      if (scope === this.#documentScope && current?.identity === expected.identity && current.content && current.version && current.fingerprint && !current.error
        && file?.version === current.version && current.version !== plan.source.version) {
        // Canonical acceptance is already known even when later native input has
        // deleted the target. Undoing that deletion must restore its accepted configuration.
        if (this.#documentEditorBinding?.identity === expected.identity) this.#documentEditorCommands?.rememberComponentConfiguration(plan.content)
        try {
          if (!applyDocumentComponentAction(current.content, action).effect.changed) return remember({ document: source.directory, content: plan.content,
            version: current.version, fingerprint: current.fingerprint, issues: plan.issues, changeId: plan.changeId, files: plan.files.map(value => ({ path: value.path, version: value.version })) })
        } catch { /* A later conflicting input keeps the original request failure and form draft. */ }
      }
      throw error
    }
  }
  /** Manual caption times reuse the canonical action plan, media preflight and original source actor. */
  async applyDocumentCaptionTiming(action: DocumentCaptionTimingAction, id: string, expected: { identity: string, version: string, fingerprint: string }, signal?: AbortSignal): Promise<DocumentChangeAnswer> {
    const source = this.#mediaSource(expected), scope = this.#documentScope
    if (!source.content || source.fingerprint !== expected.fingerprint) throw new Error('caption-source-changed')
    const request = { id, source: componentSource({ content: source.content, version: source.version!, fingerprint: source.fingerprint! }), action }
    const plan = await this.client.planDocumentAction(source.directory, request, signal)
    signal?.throwIfAborted()
    if (scope !== this.#documentScope || this.#state.authoring?.identity !== expected.identity || plan.effect.kind !== 'caption'
      || plan.changeId !== id || JSON.stringify(plan.action) !== JSON.stringify(action) || !sameComponentSource(plan.source, request.source)) throw new Error('caption-source-changed')
    this.#playback.stopPreviewOutputs()
    await this.pausePlayback()
    try {
      return await this.#documentSession.commitExternal(expected, async (_before, ownedSignal) => {
        const receipt = await this.client.applyDocumentAction(source.directory, { planId: plan.planId, digest: plan.digest, source: plan.source }, ownedSignal)
        const actual = await this.client.readDocument(source.directory)
        if (receipt.planId !== plan.planId || receipt.digest !== plan.digest || receipt.changeId !== id || !receipt.verification.saved || !receipt.verification.actionStateVerified
          || !sameComponentSource(receipt.verification.source, componentSource(actual))) throw new Error('document-confirmation')
        return { ...actual, changeId: id, files: receipt.files }
      }, (before, answer, current) => reconcileCaptionTiming(action, before, answer, current), signal)
    } catch (error) {
      const current = this.#state.authoring, file = plan.files.find(file => file.path === (source.directory ? `${source.directory}/lecture.json` : 'lecture.json'))
      // Lost ACK was independently read by commitExternal; only the exact predicted saved version can settle it.
      if (scope === this.#documentScope && current?.identity === expected.identity && current.content && current.version === file?.version && current.fingerprint && !current.error) {
        try {
          if (!applyDocumentCaptionTiming(current.content, action).effect.changed) return { document: source.directory, content: plan.content, version: current.version!, fingerprint: current.fingerprint, changeId: id, issues: plan.issues, files: plan.files.map(({ path, version }) => ({ path, version })) }
        } catch { /* A newer target stays pending with the actual request error. */ }
      }
      throw error
    }
  }
  /** Uses the current actor revision so timing edits cannot replace later body or narration input. */
  setDocumentStepProduction(stepId: string, patch: StepTimingPatch, identity?: string): void {
    const document = this.getDocument(identity)
    if (!document || isAgentChanging(this.#state)) return
    const next = changeStepTiming(document, stepId, patch)
    if (next === document) return
    this.stopAuditions()
    this.#documentSession.record(next)
  }
  documentTimingDraft(stepId: string, key: StepDurationKey, identity: string, reset: number): StepDurationDraft | undefined {
    const document = this.getDocument(identity)
    return document && this.#state.authoring?.reset === reset ? this.#documentTimingDrafts.get(identity, document, stepId, key) : undefined
  }
  /** Retains unaccepted numeric input by stable target; it is not authoring content or a second timing source. */
  setDocumentTimingDraft(stepId: string, key: StepDurationKey, draft: StepDurationDraft | undefined, identity: string, reset: number): void {
    const source = this.#state.authoring, document = this.getDocument(identity)
    if (!document?.steps[stepId] || source?.reset !== reset || isAgentChanging(this.#state)) return
    if (!this.#documentTimingDrafts.set(identity, document, stepId, key, draft)) return
    this.#set({ authoring: { ...source }, hasDocumentTimingDrafts: this.#documentTimingDrafts.has(identity, document), documentTimingDraftStorageFailure: this.#documentTimingDrafts.failed })
  }
  captionTimingDraft(stepId: string, identity: string): CaptionTimingDraft | undefined {
    const source = this.#state.authoring
    return source?.identity === identity && source.member?.id ? this.#captionTimingDrafts.get({ identity, memberId: source.member.id }, stepId) : undefined
  }
  setCaptionTimingDraft(stepId: string, draft: CaptionTimingDraft | undefined, identity: string): void {
    const source = this.#state.authoring, document = this.getDocument(identity)
    if (!document?.steps[stepId] || source?.identity !== identity || !source.member?.id) return
    const scope = { identity, memberId: source.member.id }
    this.#captionTimingDrafts.set(scope, stepId, draft)
    this.#set({ captionTimingDraftSteps: this.#captionTimingDrafts.steps(scope, document), captionTimingDraftStorageFailure: this.#captionTimingDrafts.failed(scope) })
  }
  /** Changes one stable narration target without replacing the body editor or derived captions. */
  setDocumentNarration(stepId: string, narrationId: string | undefined, content: NarrationNode[], identity?: string): string | undefined {
    const document = this.getDocument(identity)
    if (!document || isAgentChanging(this.#state) || !Object.hasOwn(document.steps, stepId)) return undefined
    const step = document.steps[stepId]!
    if (!document.sections[step.sectionId]?.stepOrder.includes(stepId)) return undefined
    if (!Array.isArray(content)) throw new Error('document-narration-content')
    let meaningful = false
    for (const node of content) {
      if (!node || typeof node !== 'object') throw new Error('document-narration-content')
      if (node.type === 'text' && typeof node.text === 'string') meaningful ||= Boolean(node.text.trim())
      else if (node.type === 'pronunciation' && typeof node.display === 'string' && typeof node.spoken === 'string') meaningful ||= Boolean(node.display.trim() || node.spoken.trim())
      else if (node.type === 'cue' && typeof node.id === 'string' && node.id && typeof node.action === 'string' && node.action) meaningful = true
      else throw new Error('document-narration-content')
    }
    const target = narrationId ?? step.narrationIds[0]
    const existing = target !== undefined && Object.hasOwn(document.narrations, target) ? document.narrations[target] : undefined
    if (existing && !step.narrationIds.includes(existing.id)) throw new Error('document-narration-target')
    if (!existing && !meaningful) return undefined
    const id = target ?? crypto.randomUUID()
    if (!id || id.length > 200 || /[\u0000-\u001f]/.test(id) || ['__proto__', 'constructor', 'prototype'].includes(id)) throw new Error('document-narration-target')
    if (!existing && (document.id === id || [document.sections, document.blocks, document.steps, document.assets, document.definitions].some(map => Object.hasOwn(map, id)))) throw new Error('document-narration-target')
    if (existing && JSON.stringify(existing.content) === JSON.stringify(content)) return id
    const next: TeachingDocument = { ...document,
      narrations: { ...document.narrations, [id]: { ...existing, id, content: structuredClone(content) } },
      ...step.narrationIds.includes(id) ? {} : { steps: { ...document.steps, [stepId]: { ...step, narrationIds: [...step.narrationIds, id] } } },
    }
    const cueIds = new Set(content.flatMap(node => node.type === 'cue' ? [node.id] : []))
    const previousCues = new Set(existing?.content.flatMap(node => node.type === 'cue' ? [node.id] : []) ?? [])
    if (document.production.cueTimings) {
      const removed = [...previousCues].filter(cue => !cueIds.has(cue) && Object.hasOwn(document.production.cueTimings!, cue)
        && !Object.values(next.narrations).some(narration => narration.content.some(node => node.type === 'cue' && node.id === cue)))
      if (removed.length) {
        const cueTimings = { ...document.production.cueTimings }
        for (const cue of removed) {
          this.#removedCueTimings.delete(cue)
          this.#removedCueTimings.set(cue, { ...cueTimings[cue]! })
          delete cueTimings[cue]
        }
        while (this.#removedCueTimings.size > 512) this.#removedCueTimings.delete(this.#removedCueTimings.keys().next().value!)
        next.production = { ...document.production, cueTimings }
      }
    }
    for (const cue of cueIds) {
      const timing = this.#removedCueTimings.get(cue)
      if (!previousCues.has(cue) && timing && !Object.hasOwn(next.production.cueTimings ?? {}, cue)) {
        next.production = { ...next.production, cueTimings: { ...next.production.cueTimings, [cue]: { ...timing } } }
        this.#removedCueTimings.delete(cue)
      }
    }
    this.stopAuditions()
    this.#documentSession.record(next)
    return id
  }
  recordDocument(content: TeachingDocument): void { if (!isAgentChanging(this.#state)) this.#documentSession.record(content) }
  documentComposition(composing: boolean, owner = 'body', identity?: string): void { this.#documentSession.composition(composing, owner, identity) }
  retryDocument(): Promise<void> { return this.#documentSession.retry() }
  discardDocument(): Promise<void> { return this.#documentSession.discard() }
  recoverDocumentDraft(value: DocumentDraftReference, identity: string): Promise<void> {
    return this.#state.authoring?.identity === identity && !isAgentChanging(this.#state) ? this.#documentSession.recoverDraft(value) : Promise.resolve()
  }
  forgetDocumentDraft(value: DocumentDraftReference, identity: string): void {
    if (this.#state.authoring?.identity === identity && !isAgentChanging(this.#state)) void this.#documentSession.forgetDraft(value)
  }
  documentError(error: Error): void { this.#set({ save: { kind: 'failed', message: error.message } }) }
  documentInteraction(): void {
    this.#documentSelectionGeneration++; this.#cancelOpeningDocument(); this.#stopDocumentReturnScroll?.()
    if (this.#state.documentPositionFailure) this.#set({ documentPositionFailure: undefined })
  }
  pendingDocumentField(blockId: string | undefined, identity?: string): Pick<DocumentFieldRegistration, 'stepId' | 'narrationId' | 'slot'> | undefined {
    const pending = this.#pendingDocumentField, source = this.#state.authoring
    return pending && pending.generation === this.#documentSelectionGeneration && pending.field.blockId === blockId
      && pending.field.identity === (identity ?? source?.identity) && pending.reset === source?.reset ? pending.field : undefined
  }
  /** A live native field owns its target only while it still has focus in this source. */
  focusedDocumentField(): Readonly<Pick<DocumentFieldRegistration, 'identity' | 'reset' | 'blockId' | 'stepId' | 'narrationId' | 'slot'>> & { readonly editor: DocumentHistoryEditor } | undefined {
    const source = this.#state.authoring, editor = this.#activeDocumentHistory
    if (!source?.identity || !source.content || !editor || editor === this.#documentEditor || editor.prosemirrorView.isDestroyed || !editor.prosemirrorView.hasFocus()) return
    for (const field of this.#documentFields.values()) {
      if (field.editor !== editor || field.identity !== source.identity || field.reset !== source.reset) continue
      const step = source.content.steps[field.stepId], section = step && source.content.sections[step.sectionId]
      if (!step || !section?.stepOrder.includes(field.stepId) || field.slot < 0 || field.slot >= Math.max(1, step.narrationIds.length)
        || step.narrationIds[field.slot] !== undefined && step.narrationIds[field.slot] !== field.narrationId) return
      const { identity, reset, blockId, stepId, narrationId, slot } = field
      return { identity, reset, blockId, stepId, narrationId, slot, editor }
    }
    return undefined
  }
  registerDocumentField(field: DocumentFieldRegistration): () => void {
    if (field.identity !== this.#state.authoring?.identity || field.reset !== this.#state.authoring.reset) return () => {}
    const key = JSON.stringify([field.identity, field.blockId, field.stepId, field.slot, field.narrationId])
    this.#documentFields.set(key, field)
    const pending = this.#pendingDocumentField
    if (pending && pending.reset === field.reset && JSON.stringify([pending.field.identity, pending.field.blockId, pending.field.stepId, pending.field.slot, pending.field.narrationId]) === key) {
      this.#pendingDocumentField = undefined
      if (pending.generation === this.#documentSelectionGeneration && field.identity === this.#state.authoring?.identity && field.reset === this.#state.authoring.reset) field.focus.restore(pending.bookmark)
    }
    return () => {
      if (this.#documentFields.get(key) !== field) return
      this.#documentFields.delete(key)
      if (field.editor && this.#activeDocumentHistory === field.editor) this.activateDocumentHistory(this.#documentEditor)
    }
  }
  /** Toolbar commands use the focused native editor's own history, without replacing the body editor. */
  activateDocumentHistory(editor: DocumentHistoryEditor | undefined): void {
    if (editor && editor !== this.#documentEditor && ![...this.#documentFields.values()].some(field => field.editor === editor && field.identity === this.#state.authoring?.identity)) return
    if (editor === this.#activeDocumentHistory) return
    this.#unsubscribeDocumentHistory?.()
    this.#activeDocumentHistory = editor
    const update = () => this.#set({ editorHistory: editor ? { section: this.#state.position?.section ?? '', undo: undoDepth(editor.prosemirrorState) > 0, redo: redoDepth(editor.prosemirrorState) > 0, field: editor !== this.#documentEditor } : undefined })
    this.#unsubscribeDocumentHistory = editor?.onChange(update)
    update()
  }
  attachDocumentEditor(editor: NativeDocumentEditor | undefined, commands?: DocumentEditorCommands, binding?: { identity: string, reset: number }): void {
    if (binding && (binding.identity !== this.#state.authoring?.identity || binding.reset !== this.#state.authoring.reset)) return
    const wasBody = !this.#activeDocumentHistory || this.#activeDocumentHistory === this.#documentEditor
    if (editor !== this.#documentEditor) this.#documentSession.composition(false, 'body')
    this.#unsubscribeDocumentSelection?.(); this.#stopDocumentLayoutScroll?.(); this.#stopDocumentLayoutScroll = undefined
    this.#documentEditor = editor; this.#documentEditorCommands = commands; this.#documentEditorBinding = editor ? binding : undefined
    if (editor) {
      const focus = this.#pendingDocumentFocus, selection = this.#pendingDocumentSelection
      this.#pendingDocumentFocus = undefined; this.#pendingDocumentSelection = undefined
      if (focus && focus.documentId === this.#state.authoring?.content?.id && focus.generation === this.#documentSelectionGeneration && editor.getBlock(focus.nodeId)) {
        // A deferred page operation adopts its selection only after the source write is acknowledged.
        if (!focus.afterSave) {
          this.#focusDocumentNode(editor, focus.nodeId)
        }
      } else if (!this.#pendingDocumentField && selection && selection.documentId === this.#state.authoring?.content?.id) {
        const anchor = nativeTextPosition(editor, selection.anchor), head = nativeTextPosition(editor, selection.head)
        if (anchor !== undefined && head !== undefined) {
          editor.transact(transaction => transaction.setSelection(TextSelection.create(transaction.doc, anchor, head)))
          if (selection.focused) editor.focus()
        }
      }
    }
    if (wasBody) this.activateDocumentHistory(editor)
    if (editor) {
      const focus = () => this.activateDocumentHistory(editor)
      const element = editor.prosemirrorView.dom
      element.addEventListener('focusin', focus)
      if (editor.prosemirrorView.hasFocus()) focus()
      const stopSelection = editor.onSelectionChange(() => { this.#documentSelectionGeneration++ })
      this.#unsubscribeDocumentSelection = () => { this.#stopDocumentLayoutScroll?.(); stopSelection(); element.removeEventListener('focusin', focus) }
      commands?.handleAnimationRequests({ available: () => this.#drafters.size > 0, request: place => this.draftAnimation(place) })
    } else this.#unsubscribeDocumentSelection = undefined
    this.#finishOpeningDocument()
  }
  /** `selectText`: the node's whole text is selected, so typing replaces it. */
  #focusDocumentNode(editor: NativeDocumentEditor, nodeId: string, selectText = false): void {
    this.#stopDocumentLayoutScroll?.(); this.#stopDocumentLayoutScroll = undefined
    editor.setTextCursorPosition(nodeId, 'start')
    const text = selectText ? nativeBlockText(editor, nodeId) : undefined
    if (text) editor.transact(transaction => transaction.setSelection(TextSelection.create(transaction.doc, text.from, text.to)))
    editor.focus()
    editor.prosemirrorView.dispatch(editor.prosemirrorState.tr.scrollIntoView())
    // React node views can finish sizing after the native editor mounts.
    // Keep only this untouched focus visible through those layout changes.
    if (typeof ResizeObserver !== 'undefined') {
      const view = editor.prosemirrorView, document = view.dom.ownerDocument
      const source = this.#state.authoring!, identity = source.identity, reset = source.reset, documentId = source.content!.id
      const request = this.#state.documentNavigation?.request, selected = editor.prosemirrorState.selection
      let size = view.dom.getBoundingClientRect(), active = true
      const events = ['pointerdown', 'wheel', 'touchmove', 'keydown', 'beforeinput']
      const cancel = () => {
        if (!active) return
        active = false; observer.disconnect(); stopFocus(); stopSelection()
        for (const event of events) document.removeEventListener(event, interact, true)
      }
      const interact = (event: Event) => { if (event.isTrusted) cancel() }
      const stopFocus = editor.onFocusChange((_, { focused }) => { if (!focused) cancel() })
      const stopSelection = editor.onSelectionChange(cancel)
      const observer = new ResizeObserver(() => {
        if (!active) return
        const current = this.#state.authoring
        if (this.#documentEditor !== editor || view.isDestroyed || !current || current.identity !== identity || current.reset !== reset
          || current?.content?.id !== documentId || this.#state.documentNavigation?.request !== request
          || !editor.isFocused() || !editor.prosemirrorState.selection.eq(selected) || editor.getTextCursorPosition().block.id !== nodeId) { cancel(); return }
        const next = view.dom.getBoundingClientRect()
        if (next.width === size.width && next.height === size.height) return
        size = next
        view.dispatch(editor.prosemirrorState.tr.scrollIntoView())
      })
      for (const event of events) document.addEventListener(event, interact, true)
      observer.observe(view.dom)
      this.#stopDocumentLayoutScroll = cancel
    }
  }
  async flushPendingEdits(signal?: AbortSignal): Promise<void> {
    if (isAgentChanging(this.#state)) throw new Error('save-locked')
    await this.#documentSession.flush(signal)
  }
  async #changeDocumentPage(change: (document: TeachingDocument) => void): Promise<boolean> {
    const source = this.#state.authoring, directory = this.#state.document
    if (!source?.content || directory === undefined || isAgentChanging(this.#state)) return false
    const scope = this.#documentScope, identity = source.identity, documentId = source.content.id
    const current = () => scope === this.#documentScope && directory === this.#state.document && identity === this.#state.authoring?.identity && documentId === this.#state.authoring?.content?.id
    this.#set({ isEditingPage: true, pageEditFailure: undefined })
    try {
      await this.flushPendingEdits()
      if (!current()) throw new Error('page-context-changed')
      const document = structuredClone(this.#state.authoring!.content!)
      change(document)
      const editor = this.#documentEditor
      this.#pendingDocumentField = undefined
      const plannedFocus = this.#pendingDocumentFocus
      if (!plannedFocus || plannedFocus.generation !== this.#documentSelectionGeneration) for (const field of this.#documentFields.values()) {
        if (field.identity !== source.identity || field.reset !== this.#state.authoring!.reset || field.blockId !== undefined && !document.blocks[field.blockId] || !document.steps[field.stepId]) continue
        const bookmark = field.focus.capture()
        if (bookmark) {
          const { focus: _focus, editor: _editor, ...target } = field
          this.#pendingDocumentField = { field: target, bookmark, reset: this.#state.authoring!.reset + 1, generation: this.#documentSelectionGeneration }
          this.#pendingDocumentSelection = undefined
          break
        }
      }
      if (editor && !this.#pendingDocumentField) {
        const state = editor.prosemirrorState, anchor = nativeTextPoint(editor, state.selection.anchor), head = nativeTextPoint(editor, state.selection.head)
        if (anchor && head) this.#pendingDocumentSelection = { documentId: document.id, anchor, head, focused: editor.prosemirrorView.hasFocus() }
      }
      this.#documentSession.replace(document)
      await this.#documentSession.flush()
      return current()
    } catch (error) { if (current()) this.#set({ pageEditFailure: this.#pageEditMessage(errorMessage(error)) }); return false }
    finally { if (current()) this.#set({ isEditingPage: false }) }
  }
  renamePage(section: string, title: string): Promise<boolean> {
    return this.#changeDocumentPage(document => { Object.assign(document, renameDocumentPage(document, section, title)) })
  }
  async duplicatePage(section: string): Promise<boolean> {
    if (this.#state.isEditingPage || this.#state.isCreatingPage || this.#state.isHistoryBusy) return false
    const scope = this.#documentScope, identity = this.#state.authoring?.identity, generation = this.#documentSelectionGeneration, navigation = this.#state.documentNavigation?.request
    let copied: string | undefined
    const saved = await this.#changeDocumentPage(document => {
      if (scope !== this.#documentScope || identity !== this.#state.authoring?.identity) throw new Error('page-context-changed')
      const page = document.sections[section]
      if (!page) throw new Error('page-boundary')
      const copy = duplicateDocumentPage(document, section, contentText(document.metadata.lang).copyTitle(page.title), () => crypto.randomUUID())
      copied = copy.section
      Object.assign(document, copy.document)
      if (generation === this.#documentSelectionGeneration) this.#pendingDocumentFocus = { documentId: document.id, nodeId: `page:${copied}`, generation, afterSave: true }
    })
    // Source confirmation owns editing. A later author selection takes precedence over this response.
    if (!saved || !copied || scope !== this.#documentScope || generation !== this.#documentSelectionGeneration || navigation !== this.#state.documentNavigation?.request || !this.#state.authoring?.content?.sections[copied]) return saved
    this.goToSection(copied)
    const editor = this.#documentEditor
    if (editor) {
      const blocks = editor.document, start = blocks.findIndex(block => block.id === `page:${copied}`)
      const first = (nodes: typeof blocks): string | undefined => {
        for (const node of nodes) { if (node.type === 'presmiumPage') break; if (node.content !== undefined) return node.id; const child = first(node.children); if (child) return child }
        return undefined
      }
      const target = start < 0 ? undefined : first(blocks.slice(start + 1)) ?? blocks[start]!.id
      if (target) { editor.setTextCursorPosition(target, 'start'); editor.focus() }
    }
    return true
  }
  movePage(section: string, before: string | null): Promise<boolean> {
    return this.#changeDocumentPage(document => { Object.assign(document, moveDocumentPage(document, section, before)) })
  }
  pageDeleteMessage(reason: string): string { return this.#pageEditMessage(reason) }
  async preparePageDeletion(section: string): Promise<PageDeletePlan> {
    await this.flushPendingEdits()
    const source = this.#state.authoring
    if (!source?.content || !source.version || this.#state.document === undefined) throw new Error('document-unavailable')
    return pageDeletion(source.content, this.#state.document, source.version, section)
  }
  async deletePage(plan: PageDeletePlan): Promise<boolean> {
    if (!plan.allowed || plan.document !== this.#state.document) return false
    return this.#changeDocumentPage(document => {
      if (this.#state.authoring?.version !== plan.source.version) throw new Error('revision-conflict')
      const current = pageDeletion(document, plan.document, plan.source.version, plan.section)
      if (!current.allowed) throw new Error(current.reason)
      Object.assign(document, removeDocumentPage(document, plan.section, () => crypto.randomUUID()))
      this.#set({ position: { section: document.sectionOrder[0]!, step: 0, trail: [], view: 'document' }, documentNavigation: { section: document.sectionOrder[0]!, request: crypto.randomUUID() } })
    })
  }
  createPage(after = this.#state.position?.section): boolean {
    const source = this.#state.authoring, editor = this.#documentEditor, commands = this.#documentEditorCommands
    if (!source?.content || source.loading || source.composing || source.restored || source.error || !editor || !commands
      || editor.prosemirrorView.isDestroyed || isAgentChanging(this.#state) || this.#state.isEditingPage || this.#state.isCreatingPage
      || this.#state.isHistoryBusy || this.#state.isHistoryPreparing) return false
    try {
      this.#set({ pageEditFailure: undefined })
      const created = commands.insertPage(after, contentText(source.content.metadata.lang).newPage)
      this.#pendingDocumentField = undefined; this.#pendingDocumentSelection = undefined; this.#pendingDocumentFocus = undefined
      this.#set({ position: { section: created.sectionId, step: 0, trail: [], view: 'document' }, documentNavigation: { section: created.sectionId, request: crypto.randomUUID() } })
      // The default title is selected: the author's first words name the page, and Enter goes on to its body.
      this.#focusDocumentNode(editor, `page:${created.sectionId}`, true)
      return true
    } catch (error) { this.#set({ pageEditFailure: this.#pageEditMessage(errorMessage(error)) }); return false }
  }

  /** Points at a block or a passage (none: stops pointing): the preview outlines it and the conversation is told. */
  point(pointer: Pointer | undefined): void {
    const pointed = this.#state.pointer !== undefined || this.#state.focus.length > 0
    if (pointer) this.#record('point', { 'presmium.section': pointer.target.section, 'presmium.block': pointer.target.block, 'presmium.step': pointer.step, 'presmium.quote': pointer.quote, 'presmium.editable': pointer.isEditable })
    this.#set({ pointer, askFailure: undefined })
    this.#broadcast({ type: 'highlight', target: pointer?.target ?? null })
    if (pointer) this.setFocus([this.#focusReportOf(pointer)])
    else if (pointed) this.setFocus([])
  }

  #focusReportOf(pointer: Pointer): FocusReport {
    const { target, step, quote } = pointer
    return { id: 'selection', from: this.#state.previewRenderer, section: target.section, ...target.block ? { block: target.block } : {}, ...step === undefined ? {} : { step }, ...quote ? { quote: quote.slice(0, 2000) } : {} }
  }

  setFocus(focus: FocusReport[]): void {
    const document = this.#state.document
    if (document === undefined) return
    const version = ++this.#focusVersion
    this.#set({ focus, isFocusPending: true, focusFailure: undefined })
    this.#focusWriting = this.#focusWriting.then(async () => {
      if (version !== this.#focusVersion) return
      try {
        const answer = await this.client.writeFocus(document, focus)
        if (version === this.#focusVersion && document === this.#state.document) this.#set({ focus: answer.focus ?? [], isFocusPending: false })
      } catch (error) {
        if (version === this.#focusVersion && document === this.#state.document) this.#set({ isFocusPending: false, focusFailure: errorMessage(error) })
      }
    })
  }

  /** Hands `instruction` about what the author points at (or `focus`) to the conversation's agent. */
  askAgent(instruction: string, focus: FocusReport[] = this.#state.pointer ? [this.#focusReportOf(this.#state.pointer)] : []): Promise<boolean> {
    return this.#ask('request', focus, places => this.#messages.agentRequest(instruction, [...places.values()]))
  }

  /**
   * Sets `focus` as the document's focus, then sends the agent the message `compose` writes from the places, by focus id.
   * The message names places as the author sees them; the agent's document context has their source ranges.
   */
  async #ask(kind: 'request' | 'comments', focus: FocusReport[], compose: (places: Map<string, AgentPlace>) => string): Promise<boolean> {
    const document = this.#state.document
    const asker = [...this.#askers][0]
    if (document === undefined) return false
    // The request's id joins the operation to the conversation: the session's user message carries it, and its turn follows.
    const asked = (attributes: Record<string, OperationValue>) => this.#record('agent.ask', { 'presmium.ask.kind': kind, 'presmium.focus': focus.map(item => [item.id, item.section, item.block].filter(Boolean).join(':')).join('\n'), ...attributes })
    if (!asker) {
      asked({ 'presmium.outcome': 'no-conversation' })
      this.#set({ askFailure: 'no-conversation' })
      return false
    }
    this.#set({ isAsking: true, askFailure: undefined })
    let text: string | undefined
    try {
      if (focus.length > 0) {
        this.setFocus(focus)
        await this.#focusWriting
      }
      const located = this.#state.focus as (FocusReport & Partial<Focus>)[]
      const places = new Map(located.map(item => [item.id, this.#describe(item)]))
      text = compose(places)
      const answer = await asker({ text, focus })
      asked({ 'presmium.ask.text': text, 'presmium.outcome': 'sent', 'dsh.session.id': answer?.session, 'dsh.request.id': answer?.request })
      if (this.#state.document === document) this.#set({ isAsking: false, pointer: undefined })
      this.#broadcast({ type: 'highlight', target: null })
      return true
    } catch (error) {
      asked({ 'presmium.ask.text': text, 'presmium.outcome': 'failed', 'presmium.error': errorMessage(error) })
      if (this.#state.document === document) this.#set({ isAsking: false, askFailure: errorMessage(error) })
      return false
    }
  }

  /**
   * Opens the conversation with a request, for the author to describe and send, to add an animation at `place` of the
   * open document; the document's focus points the AI at the place's block. Nothing is inserted or sent.
   */
  draftAnimation(place: DocumentInsertionPlace): boolean {
    const document = this.#state.document, content = this.#state.authoring?.content, drafter = [...this.#drafters][0]
    const section = content?.sections[place.sectionId]
    if (document === undefined || !content || !section || !drafter) return false
    const report = (block?: string): FocusReport => ({ id: 'insertion', from: this.#state.previewRenderer, section: place.sectionId, ...block ? { block } : {}, ...place.text ? { quote: place.text.slice(0, 2000) } : {} })
    const text = this.#messages.animationRequest(content.sectionOrder.indexOf(place.sectionId) + 1, section.title, place)
    const focus = [report(place.blocks[0])]
    void this.#focusFirst(document, [...place.blocks.map(block => [report(block)]), [report()], []])
    const drafted = (attributes: Record<string, OperationValue>) => this.#record('agent.draft', { 'presmium.ask.kind': 'animation', 'presmium.ask.text': text, 'presmium.section': place.sectionId, 'presmium.block': place.blocks[0], ...attributes })
    this.#set({ askFailure: undefined })
    void (async () => drafter({ text, focus }))().then(() => drafted({ 'presmium.outcome': 'drafted' }), (error: unknown) => {
      drafted({ 'presmium.outcome': 'failed', 'presmium.error': errorMessage(error) })
      if (this.#state.document === document) this.#set({ askFailure: errorMessage(error) })
    })
    return true
  }

  /**
   * Sets the first of `choices` the service can locate as the document's focus. Its runtime snapshot is compiled from the
   * saved source, which may not have a block or page created moments ago yet; the last choice leaves no stale focus.
   */
  async #focusFirst(document: string, choices: FocusReport[][]): Promise<void> {
    for (const focus of choices) {
      this.setFocus(focus)
      const version = this.#focusVersion
      await this.#focusWriting
      if (version !== this.#focusVersion || this.#state.document !== document || !this.#state.focusFailure) return
    }
  }

  /** A place as the author sees it: the section's title and the passage. */
  #describe(item: FocusReport & Partial<Focus>): AgentPlace {
    const doc = this.#state.ir?.doc
    const section = item.section ? doc?.sections.find(s => s.id === item.section)?.title ?? item.section : undefined
    const quote = item.quote ?? item.excerpt
    return { ...section ? { section } : {}, ...quote ? { quote: quote.slice(0, 1500) } : {} }
  }

  /** Shows a section, or a block of it, in the preview (the next one attached, when none is). */
  revealPlace(section: string, block?: string): void {
    this.#record('reveal.place', { 'presmium.section': section, 'presmium.block': block })
    const target: PreviewAnchor = { section, ...block ? { block } : {} }
    if (this.#previewSinks.size === 0) {
      this.#pendingReveal = target
      return
    }
    this.#broadcast({ type: 'go', section })
    this.#broadcast({ type: 'highlight', target })
  }

  /** Shows a section in the preview: from the navigation pane, the section to read or the slide to show. */
  goToSection(section: string): void {
    if (this.#state.webView === 'document' && this.#state.authoring?.content?.sections[section]) {
      this.#set({ position: { section, step: 0, trail: [], view: 'document' }, documentPositionFailure: undefined, documentNavigation: { section, request: crypto.randomUUID() } }); return
    }
    this.goToStep(section, this.#selectedSteps.get(`${this.#state.document}\0${section}`) ?? 0)
  }

  /** Material references locate a canonical block in Doc without entering a runtime view or rewriting selection. */
  revealDocumentComponent(blockId: string, identity: string): void {
    const source = this.#state.authoring, document = this.getDocument(identity)
    if (!document || source?.identity !== identity || this.#state.webView !== 'document' || document.blocks[blockId]?.type !== 'component') return
    const sectionId = documentComponentSection(document, blockId), section = document.sections[sectionId]!
    const current = this.#state.position?.section === sectionId ? this.#state.position.step : this.#selectedSteps.get(`${this.#state.document}\0${sectionId}`) ?? 0
    const visible = (index: number) => Boolean(section.stepOrder[index] && effectiveComponentParameters(document, blockId, section.stepOrder[index]).visual?.visible)
    const step = visible(current) ? current : Math.max(0, section.stepOrder.findIndex((_, index) => visible(index)))
    this.#set({ position: { section: sectionId, step, trail: [], view: 'document' }, documentPositionFailure: undefined,
      documentNavigation: { section: sectionId, block: blockId, request: crypto.randomUUID() } })
  }

  observeDocumentSection(section: string): void {
    if (this.#state.webView !== 'document' || !this.#state.authoring?.content?.sections[section]) return
    if (this.#state.documentPositionFailure) this.#set({ documentPositionFailure: undefined })
    if (this.#state.position?.section === section) return
    this.#set({ position: { section, step: this.#selectedSteps.get(`${this.#state.document}\0${section}`) ?? 0, trail: [], view: 'document' } })
  }

  /** A read-only inline runtime chooses a source step without navigating or editing the document. */
  observeDocumentStep(sectionId: string, stepId: string): void {
    this.#documentStep(sectionId, stepId, false)
  }

  /** An author freezes this target even when its step has not changed. */
  pauseDocumentStep(sectionId: string, stepId: string): void {
    this.#interruptProductionNavigation()
    this.#documentStep(sectionId, stepId, true)
  }

  /** A canonical step remains selectable while its next runtime snapshot is still compiling. */
  selectDocumentStep(sectionId: string, stepId: string): Promise<ProductionPlaybackState | undefined> | void {
    this.#interruptProductionNavigation()
    const source = this.#state.authoring, section = source?.content?.sections[sectionId]
    const step = section?.stepOrder.indexOf(stepId) ?? -1
    if (!section || step < 0) return
    this.#pendingGo = undefined
    if (this.#state.webView === 'document') { this.pauseDocumentStep(sectionId, stepId); return }
    const plan = this.#state.productionPlan, range = plan?.pages.find(page => page.sectionId === sectionId)?.steps.find(step => step.stepId === stepId)
    if (plan?.canPreview && this.#productionReady() && range?.startMs !== undefined) {
      this.stopAuditions()
      if (this.#production.selecting(sectionId, stepId)) return
      this.#selectedSteps.set(`${this.#state.document}\0${sectionId}`, step)
      this.#set({ position: { section: sectionId, step, trail: [], view: 'present' } })
      return this.controlProduction({ binding: plan.binding, action: 'step', sectionId, stepId }).catch(() => undefined)
    }
    const snapshot = this.#state.presentationSnapshot
    const compiled = this.#state.ir?.doc.sections.find(page => page.id === sectionId)?.stepIds?.indexOf(stepId) ?? -1
    if (snapshot && snapshot.version === source?.version && snapshot.fingerprint === source.fingerprint && compiled >= 0) this.goToStep(sectionId, compiled)
    else this.#set({ position: { section: sectionId, step, trail: [], view: this.#state.webView } })
  }

  #documentStep(sectionId: string, stepId: string, pause: boolean): void {
    const content = this.#state.authoring?.content, section = content?.sections[sectionId]
    if (this.#state.webView !== 'document' || !content?.sectionOrder.includes(sectionId) || !section) return
    const step = section.stepOrder.indexOf(stepId)
    if (step < 0 || content.steps[stepId]?.sectionId !== sectionId) return
    if (this.#state.documentPositionFailure) this.#set({ documentPositionFailure: undefined })
    this.#selectedSteps.set(`${this.#state.document}\0${sectionId}`, step)
    const same = this.#state.position?.section === sectionId && this.#state.position.step === step
    if (same && !pause) return
    this.#set({ position: { section: sectionId, step, trail: section.stepOrder.slice(1, step + 1).map(id => `step:${id}`), view: 'document' }, documentPositionIntent: same && pause ? 'pause' : 'seek' })
  }

  /** Complete-IR position; trail maps the same step into a filtered preview without reusing its ordinal. */
  goToStep(section: string, step: number): void {
    this.#interruptProductionNavigation()
    const target = this.#state.ir?.doc.sections.find(item => item.id === section)
    if (!target || !Number.isInteger(step) || step < 0 || step > target.steps) return
    const trail = stepTrail(stepKeys(target), step)
    this.#set({ position: { section, step, trail, view: this.#state.webView } })
    this.#record('navigate', { 'presmium.section': section, 'presmium.step': step })
    this.#selectedSteps.set(`${this.#state.document}\0${section}`, step)
    this.#lastPosition = undefined
    const command = { type: 'go', section, step, trail, view: this.#state.webView } as const
    if (this.#previewSinks.size === 0) {
      this.#pendingGo = { document: this.#state.document, command }
      return
    }
    this.#broadcast(command)
  }

  /** Shows the place of a line of a source file: in the document holding the file, opened when it is another. */
  async revealSource(path: string, line = 1): Promise<boolean> {
    this.#record('reveal.source', { 'presmium.path': path, 'presmium.line': line })
    const documents = this.#state.documents?.documents ?? []
    const holder = [...documents].sort((a, b) => b.dir.length - a.dir.length).find(doc => !doc.dir || path.startsWith(`${doc.dir}/`))
    if (!holder) return false
    if (holder.dir !== this.#state.document) await this.openDocument(holder.dir)
    const doc = this.#state.ir?.doc
    const place = doc && findPlaceAtLine(doc, path, line)
    if (!place) return false
    this.revealPlace(place.section, place.block)
    return true
  }

  undo(): Promise<boolean> { if (this.#state.mode !== 'editing') return Promise.resolve(false); if (this.#activeDocumentHistory && (this.#state.editorHistory?.undo || this.#state.editorHistory?.field)) { this.#activeDocumentHistory.focus(); return Promise.resolve(this.#activeDocumentHistory.undo()) } return this.#changeHistory('undo') }
  redo(): Promise<boolean> { if (this.#state.mode !== 'editing') return Promise.resolve(false); if (this.#activeDocumentHistory && (this.#state.editorHistory?.redo || this.#state.editorHistory?.field)) { this.#activeDocumentHistory.focus(); return Promise.resolve(this.#activeDocumentHistory.redo()) } return this.#changeHistory('redo') }

  async #changeHistory(action: 'undo' | 'redo' | 'revert', revision?: string): Promise<boolean> {
    if (this.#state.mode !== 'editing' || this.#state.isCreatingPage || this.#state.isEditingPage || this.#state.isHistoryBusy || this.#state.isHistoryPreparing) return false
    const document = this.#state.document, scope = this.#documentScope
    if (document === undefined) return false
    const current = () => this.#state.document === document && this.#documentScope === scope
    this.#set({ isHistoryPreparing: true, historyFailure: undefined })
    try {
      if (!current()) return false
      await this.flushPendingEdits()
      if (!current()) return false
      await this.#history.read(document)
      if (!current()) return false
      const done = await this.#timed(`history.${action}`, revision ? { 'presmium.revision': revision } : {}, () => action === 'revert' ? this.#history.revert(revision!) : this.#history[action](), done => done ? 'done' : 'refused')
      if (done && current()) await this.#documentSession.refresh(document)
      return done
    } catch {
      if (current()) this.#set({ historyFailure: this.#messages.historyPendingSave })
      return false
    } finally { if (current()) this.#set({ isHistoryPreparing: false }) }
  }

  readHistoryPage(offset: number): Promise<void> { return this.#history.page(offset) }

  revertRevision(id: string): Promise<boolean> {
    return this.#changeHistory('revert', id)
  }

  acceptReview(): Promise<boolean> {
    return this.#timed('review.accept', {}, () => this.#history.acceptReview(), done => (done ? 'done' : 'refused'))
  }

  rejectReview(): Promise<boolean> {
    return this.#timed('review.reject', {}, () => this.#history.rejectReview(), done => (done ? 'done' : 'refused'))
  }

  /** Makes the document into `target` (a renderer, or an export of its plugins) as a job of the workspace server. */
  startJob(target: string): Promise<void> {
    const document = this.#state.document, scope = this.#documentScope, identity = this.#state.authoring?.identity
    if (document === undefined || !identity || this.#state.jobPreparing) return Promise.resolve()
    const controller = this.#jobPreparation = new AbortController()
    const current = () => document === this.#state.document && scope === this.#documentScope && identity === this.#state.authoring?.identity && !controller.signal.aborted
    this.#set({ jobPreparing: { target, canCancel: true }, jobsFailure: undefined })
    return this.#timed('job.start', { 'presmium.job.target': target }, async () => {
      const stop = this.observeJobs()
      try {
        if (this.#state.hasDocumentTimingDrafts || this.#state.documentTimingDraftStorageFailure) throw new Error('job-timing-draft')
        if (this.#state.captionTimingDraftSteps?.length || this.#state.captionTimingDraftStorageFailure) throw new Error('job-caption-draft')
        await this.flushPendingEdits(controller.signal)
        if (!current()) return
        const source = this.#state.authoring
        if (!source?.version || !source.fingerprint || source.dirty || source.saving || source.composing || source.restored || source.error) throw new Error('job-save-required')
        if (this.#state.hasDocumentTimingDrafts || this.#state.documentTimingDraftStorageFailure) throw new Error('job-timing-draft')
        if (this.#state.captionTimingDraftSteps?.length || this.#state.captionTimingDraftStorageFailure) throw new Error('job-caption-draft')
        this.#set({ jobPreparing: { target, canCancel: false } })
        await this.#jobs.start(document, target, { version: source.version, fingerprint: source.fingerprint })
      } catch (error) {
        if (current()) this.#set({ jobsFailure: errorMessage(error) })
      } finally {
        stop()
        if (this.#jobPreparation === controller) {
          this.#jobPreparation = undefined
          if (current()) this.#set({ jobPreparing: undefined })
        }
      }
    })
  }

  /** Cancels the pending export intent; a document save already sent retains its own acknowledgement. */
  cancelJobPreparation(): void {
    if (!this.#state.jobPreparing?.canCancel) return
    this.#jobPreparation?.abort()
    this.#jobPreparation = undefined
    this.#set({ jobPreparing: undefined })
  }

  cancelJob(id: string): Promise<void> {
    return this.#timed('job.cancel', { 'presmium.job.id': id }, async () => { const stop = this.observeJobs(); try { await this.#jobs.cancel(id) } finally { stop() } })
  }

  recordOutsideChanges(): Promise<boolean> {
    return this.#timed('changes.record', {}, () => this.#history.recordOutside(this.#messages.outsideChange), done => (done ? 'done' : 'refused'))
  }

  /** Attaches the shown preview and selects the requested view in its existing runtime. */
  attachPreview(sink: PreviewSink): () => void {
    this.#previewSinks.add(sink)
    this.#set({ previewConnection: this.#state.previewConnection + 1 })
    if (this.#state.previewRenderer === 'web') this.#showWebView([sink])
    const pending = this.#pendingGo
    if (pending && pending.document === this.#state.document) sink.send(pending.command)
    this.#pendingGo = undefined
    sink.send({ type: 'appearance', dark: this.#state.isDark })
    sink.send({ type: 'pick', on: false })
    const reveal = this.#pendingReveal
    this.#pendingReveal = undefined
    if (reveal) sink.send({ type: 'go', section: reveal.section })
    sink.send({ type: 'highlight', target: reveal ?? this.#state.pointer?.target ?? null })
    return () => {
      this.#previewSinks.delete(sink)
      this.#production.detach(sink)
      this.#set({ previewConnection: this.#state.previewConnection + 1 })
      this.#playback.detach(sink)
    }
  }

  /** A report of the shown preview. */
  handlePreviewReport(document: string, renderer: PreviewRenderer, report: PreviewReport, origin?: PreviewSink): void {
    const unsupported = 'document-authoring-required'
    if (report.type === 'edited' && report.request !== undefined) { origin?.send({ type: 'edit-result', request: report.request, target: report.target, error: unsupported }); return }
    if (report.type === 'blocks-edited') { origin?.send({ type: 'blocks-result', section: report.section, request: report.request, error: unsupported }); return }
    if (report.type === 'source-read') { origin?.send({ type: 'source-result', section: report.section, block: report.block, request: report.request, error: unsupported }); return }
    if (report.type === 'source-save') { origin?.send({ type: 'source-saved', section: report.section, block: report.block, request: report.request, error: unsupported }); return }
    if (report.type === 'source-create') { origin?.send({ type: 'source-created', section: report.section, request: report.request, error: unsupported }); return }
    if (report.type === 'page-create') { origin?.send({ type: 'page-created', section: report.section, request: report.request, error: unsupported }); return }
    if (document !== this.#state.document || renderer !== this.#state.previewRenderer) return
    this.#playback.report(document, report, origin)
    this.#production.report(document, report, origin)
    switch (report.type) {
      case 'ready':
      case 'updated':
        if (report.revision !== this.#state.previewIrRevision) this.#playback.stopPreviewOutputs()
        this.#set({ previewIrRevision: report.revision })
        this.#flashChanges()
        if (report.type === 'ready' && renderer === 'web' && this.#state.webView !== 'document') this.#showWebView()
        break
      case 'picked': {
        if (this.#state.mode !== 'editing') break
        const target: PreviewAnchor = { section: report.section, ...report.block ? { block: report.block } : {} }
        const isEditable = false
        const section = this.#state.ir?.doc.sections.find(s => s.id === report.section)
        const step = section && report.trail ? trailStep(stepKeys(section), report.trail) : report.step
        this.point({ target, ...step === undefined ? {} : { step }, ...report.rect ? { rect: report.rect } : {}, ...report.point ? { point: report.point } : {}, isEditable })
        break
      }
      case 'selection': {
        if (this.#state.mode !== 'editing') break
        const selected = report.selection
        if (selected?.text.trim()) {
          const target: PreviewAnchor = { section: selected.section, ...selected.block ? { block: selected.block } : {} }
          this.point({ target, quote: selected.text.trim(), ...selected.rect ? { rect: selected.rect } : {}, ...selected.obstacles ? { obstacles: selected.obstacles } : {}, isEditable: false })
        } else if (this.#state.pointer?.quote) this.point(undefined)
        break
      }
      case 'flashed':
        if (this.#trail) this.#set({ changeTrail: { id: this.#trail, rects: report.rects } })
        break
      case 'rect':
        if (this.#state.pointer) this.#set({ pointer: { ...this.#state.pointer, rect: report.rect ?? undefined, obstacles: report.obstacles } })
        break
      case 'position': {
        const snapshot = this.#state.presentationSnapshot, source = this.#state.authoring
        if (snapshot && source && (source.dirty || source.loading || source.version !== snapshot.version || source.fingerprint !== snapshot.fingerprint)) break
        // An initial loader can report its old cursor while a newer stable selection is preparing.
        // Explicit native exits still reach the outer document workspace.
        if (report.view !== 'document' && this.#production.selecting()) break
        // A fresh frame announces its document view before applying the queued
        // presentation command. Only the requested view completes that switch.
        if (renderer === 'web' && this.#awaitingView) {
          if (report.view !== this.#awaitingView) break
          this.#awaitingView = undefined
        }
        // Where the author reads or presents: recorded when it moves to another section, step or view.
        const at = `${report.section}\0${report.step}\0${report.view ?? ''}`
        if (at === this.#lastPosition) break
        this.#lastPosition = at
        const section = this.#state.ir?.doc.sections.find(section => section.id === report.section)
        const completeStep = section && report.trail.length ? trailStep(stepKeys(section), report.trail) : report.step
        this.#selectedSteps.set(`${this.#state.document}\0${report.section}`, completeStep)
        this.#set({ position: { section: report.section, step: completeStep, trail: report.trail, ...report.view ? { view: report.view } : {} } })
        if (renderer === 'web' && report.view && (report.view === 'present') !== (this.#state.webView === 'present')) this.setWebView(report.view === 'present' ? 'present' : 'document')
        this.#record('preview.position', { 'presmium.section': report.section, 'presmium.step': report.step, 'presmium.preview.view': report.view })
        break
      }
      case 'shortcut':
        this.#record('shortcut', { 'presmium.command': report.command })
        if (report.command === 'dismiss') {
          if (this.#state.pointer) this.point(undefined)
        } else if (report.command === 'undo') void this.undo()
        else if (report.command === 'redo') void this.redo()
        else if (report.command === 'save') void this.flushPendingEdits().catch(() => {})
        else for (const listener of this.#playbackKeys) listener(report.command)
        break
      default:
        break
    }
  }

  #broadcast(command: PreviewCommand): void {
    for (const sink of this.#previewSinks) sink.send(command)
  }

  #stopSubscriptions(): void {
    this.#componentPropertyDrafts.stopAgentRead()
    this.#saveDocumentReturnPoint(); this.#cancelOpeningDocument(); this.#stopDocumentReturnScroll?.()
    this.#documentViewport?.stop(); this.#documentViewport = undefined; this.#documentEditorBinding = undefined
    this.#presentationPreparation++
    this.#presentationController?.abort(new Error('client-document-changed'))
    this.#voiceover.stop()
    this.#production.reset()
    this.#set({ isPreparingPresentation: false, presentationFailure: undefined, presentationSnapshot: undefined, productionPlan: undefined, productionFailure: undefined })
    this.#removedCueTimings.clear(); this.#cueTimingScope = undefined
    this.#documentSession.close()
    this.#unsubscribeDocumentHistory?.(); this.#unsubscribeDocumentHistory = undefined; this.#activeDocumentHistory = undefined; this.#documentEditor = undefined
    this.#unsubscribeDocumentSelection?.(); this.#unsubscribeDocumentSelection = undefined
    this.#pendingDocumentFocus = undefined; this.#pendingDocumentSelection = undefined; this.#documentSelectionGeneration++
    this.#documentFields.clear(); this.#pendingDocumentField = undefined
    this.#playback.reset()
    this.#focusVersion++
    this.#pendingReveal = undefined
    this.#awaitingView = undefined
    this.#pendingFlash = undefined
    this.#unsubscribeIr?.()
    this.#unsubscribeIr = undefined
    this.#runtimeDocument = undefined
    this.#jobObservers.clear()
    this.#jobsDocument = undefined
    this.#history.stop()
    this.#diagnostics.stop()
    this.#jobs.stop()
  }

  dispose(): void {
    this.telemetry.stop()
    this.#stopSubscriptions()
    this.#diagnostics.dispose()
    this.#documentSession.dispose()
    this.#commentDrafts.close()
    this.#componentPropertyDrafts.close()
    this.#captionTimingDrafts.close()
    disposeTranscriptionSessions(this.client)
    disposeDocumentRecordings(this)
  }
}
