import { BaseService, type BaseServiceContext } from '@learnitive/base-utils';
import { SERVICE_NAME } from '@learnitive/constants';
import {
  collectProjectCompileFiles,
  type LatexCompileFile,
  type LatexLiveDraftCompiler,
  type LatexLiveDraftProbeResult,
  type LatexLiveDraftRenderResult,
} from '@learnitive/latex-compiler';
import { atom } from 'jotai';
import { projectFilePath } from '@learnitive/project-storage';
import { WsPath } from '@learnitive/ws-path';
import type { LatexSourceMutationEvent, LatexSourceService } from './latex-source-service';
import type { ProjectCollaborationService } from './project-collaboration-service';
import type { ProjectStorageService } from './project-storage-service';
import type { ProjectEntitlementService } from './project-entitlement-service';
import type { ProjectPdfService } from './project-pdf-service';
import { fullTexliveFallbackReasonFromText } from './project-compile-service';
import {
  decideLiveInstantParagraph,
  liveInstantOriginEligible,
  selectLiveInstantTarget,
  type LiveInstantTarget,
} from './project-live-instant';
import {
  hasSessionCloudFallback,
  rememberSessionCloudFallback,
} from './project-cloud-fallback-session';

export type ProjectLiveDraftStatus =
  | 'idle'
  | 'preparing'
  | 'probing'
  | 'success'
  | 'error'
  | 'cancelled';

export type ProjectLiveDraftState = {
  status: ProjectLiveDraftStatus;
  wsName?: string;
  result?: LatexLiveDraftProbeResult;
  error?: string;
};

export type ProjectLivePdfStatus =
  | 'disabled'
  | 'certifying'
  | 'rendering'
  | 'ready'
  | 'error';

export type ProjectLivePdfInstantStatus = 'idle' | 'rendering' | 'ready';

export type ProjectLiveInstantDiagnosticReason =
  | 'unsupported-runtime'
  | 'cloud-backend'
  | 'unsupported-file'
  | 'unsupported-origin'
  | 'multiple-edits'
  | 'stale-generation'
  | 'no-r5-baseline'
  | 'source-conflict'
  | 'multiple-files'
  | 'preamble-change'
  | 'structural-edit'
  | 'environment-context'
  | 'multiple-paragraphs'
  | 'synctex-target'
  | 'compiler-failure'
  | 'line-count-change'
  | 'viewer-paint-failure'
  | 'superseded'
  | 'other';

export type ProjectLiveInstantDiagnostics = {
  mutationCount: number;
  eligibleMutationCount: number;
  renderedPatchCount: number;
  viewerAcceptedPatchCount: number;
  viewerRejectedPatchCount: number;
  rejectedMutationCount: number;
  rejectionCounts: Readonly<Partial<Record<ProjectLiveInstantDiagnosticReason, number>>>;
  warmupCount: number;
  warmupFailureCount: number;
  lastWarmupDurationMs?: number;
  lastBlockCompileDurationMs?: number;
  lastPublishLatencyMs?: number;
  lastPatchLifetimeMs?: number;
  workerFailureCount: number;
  updatedAt: number;
};

export type ProjectLivePdfInstantPatch = {
  generation: number;
  /** Exact R5 generation whose PDF/SyncTeX geometry anchors this transient block. */
  baseGeneration: number;
  sourcePath: string;
  sourceRevision: number;
  target: LiveInstantTarget;
  pdf: Uint8Array;
  durationMs: number;
  createdAt: number;
};

export type ProjectLivePdfState = {
  status: ProjectLivePdfStatus;
  wsName?: string;
  enabled: boolean;
  certified: boolean;
  pdf?: Uint8Array;
  /** Same-generation SyncTeX emitted with `pdf`; never mixed across generations. */
  synctex?: Uint8Array;
  rootFile?: string;
  generation?: number;
  log?: string;
  pageCount?: number;
  durationMs?: number;
  certificate?: LatexLiveDraftProbeResult['certificate'];
  error?: string;
  /** Runtime used for the currently displayed Live PDF generation. */
  backend?: 'busytex' | 'cloud';
  /** R6 is transient only; the exact R5 PDF remains the authority. */
  instantStatus?: ProjectLivePdfInstantStatus;
  instantPatch?: ProjectLivePdfInstantPatch;
  instantReason?: string;
};

type Config = {
  createLiveDraftCompiler: () => LatexLiveDraftCompiler;
  /** Fail-closed R6 switch. Undefined means enabled when the compiler exposes renderInstant(). */
  instantLivePdf?: boolean;
};

type LivePdfBaseline = {
  generation: number;
  rootFile: ReturnType<typeof projectFilePath>;
  files: LatexCompileFile[];
  textByPath: Map<string, string>;
};

type PendingInstantMutation = {
  event: LatexSourceMutationEvent;
  token: number;
  queuedAt: number;
};

const INSTANT_EDIT_DEBOUNCE_MS = 45;
const INSTANT_IDLE_DISPOSE_MS = 5 * 60_000;
const liveSourceDecoder = new TextDecoder('utf-8');
const liveSourceEncoder = new TextEncoder();
const LIVE_SOURCE_PATH = /\.(?:tex|ltx)$/iu;

/**
 * R4/R5 browser Live PDF service. R4 certification is intentionally independent
 * of the manuscript; R5 rendering compiles the canonical project snapshot
 * directly. Neither path depends on or mutates ProjectCompileService state.
 */
export class ProjectLiveDraftService extends BaseService {
  static deps = ['latexSource', 'projectStorage', 'projectCollaboration', 'projectEntitlements', 'projectPdf'] as const;

  readonly $state = atom<ProjectLiveDraftState>({ status: 'idle' });
  readonly $livePdfState = atom<ProjectLivePdfState>({
    status: 'disabled',
    enabled: false,
    certified: false,
  });
  /** Developer-local R6 counters only. They are never sent as telemetry. */
  readonly $instantDiagnostics = atom<ProjectLiveInstantDiagnostics>({
    mutationCount: 0,
    eligibleMutationCount: 0,
    renderedPatchCount: 0,
    viewerAcceptedPatchCount: 0,
    viewerRejectedPatchCount: 0,
    rejectedMutationCount: 0,
    rejectionCounts: {},
    warmupCount: 0,
    warmupFailureCount: 0,
    workerFailureCount: 0,
    updatedAt: Date.now(),
  });

  private compiler: LatexLiveDraftCompiler | undefined;
  private probeRunning: AbortController | undefined;
  private liveRunning: AbortController | undefined;
  private generation = 0;
  private liveGeneration = 0;
  private liveTimer: ReturnType<typeof setTimeout> | undefined;
  /** Successful R4 result is reusable for the lifetime of this dedicated BusyTeX compiler. */
  private runtimeCertification: LatexLiveDraftProbeResult | undefined;
  private readonly liveBaselines = new Map<string, LivePdfBaseline>();
  private readonly dirtyPaths = new Map<string, Set<string>>();
  private instantTimer: ReturnType<typeof setTimeout> | undefined;
  private instantRunning: { controller: AbortController; token: number } | undefined;
  private pendingInstant: PendingInstantMutation | undefined;
  private instantGeneration = 0;
  private instantIdleTimer: ReturnType<typeof setTimeout> | undefined;
  private readonly instantViewerOutcomes = new Map<number, 'accepted' | 'rejected'>();

  constructor(
    context: BaseServiceContext,
    private readonly dependencies: {
      latexSource: LatexSourceService;
      projectStorage: ProjectStorageService;
      projectCollaboration: ProjectCollaborationService;
      projectEntitlements: ProjectEntitlementService;
      projectPdf: ProjectPdfService;
    },
    private readonly config: Config,
  ) {
    super(SERVICE_NAME.projectLiveDraftService, context, dependencies);
  }

  hookMount(): void {
    const unsubscribe = this.dependencies.latexSource.subscribeToMutations((event) => {
      const live = this.store.get(this.$livePdfState);
      if (!live.enabled || !live.certified || live.wsName !== event.wsName) return;
      const path = WsPath.assertFile(event.wsPath).filePath;
      const dirty = this.dirtyPaths.get(event.wsName) ?? new Set<string>();
      dirty.add(path);
      this.dirtyPaths.set(event.wsName, dirty);
      this.scheduleInstantPdf(event);
      this.scheduleLivePdf(event.wsName);
    });
    this.addCleanup(unsubscribe);
    const unsubscribePdf = this.store.sub(this.dependencies.projectPdf.$version, () => {
      const live = this.store.get(this.$livePdfState);
      const patch = live.instantPatch;
      if (!live.wsName || !patch) return;
      const accepted = this.dependencies.projectPdf.getWorkspaceState(live.wsName);
      if (
        accepted?.origin === 'live' &&
        accepted.generation > patch.baseGeneration
      ) {
        // The exact newer PDF/SyncTeX pair is now the display authority; release
        // the transient PDF bytes instead of retaining a hidden stale patch.
        this.clearInstantPatch();
      }
    });
    this.addCleanup(unsubscribePdf);
    this.addCleanup(() => {
      this.cancel();
      this.cancelInstantRender();
      this.compiler?.dispose();
      this.compiler = undefined;
      this.runtimeCertification = undefined;
      this.liveBaselines.clear();
      this.dirtyPaths.clear();
      if (this.liveTimer) clearTimeout(this.liveTimer);
      if (this.instantTimer) clearTimeout(this.instantTimer);
      if (this.instantIdleTimer) clearTimeout(this.instantIdleTimer);
      this.liveTimer = undefined;
      this.instantTimer = undefined;
      this.instantIdleTimer = undefined;
      this.instantViewerOutcomes.clear();
      this.store.set(this.$state, { status: 'idle' });
      this.store.set(this.$livePdfState, {
        status: 'disabled',
        enabled: false,
        certified: false,
      });
    });
  }

  private updateInstantDiagnostics(patch: Partial<ProjectLiveInstantDiagnostics>): void {
    const current = this.store.get(this.$instantDiagnostics);
    this.store.set(this.$instantDiagnostics, { ...current, ...patch, updatedAt: Date.now() });
  }

  private recordInstantRejection(reason: ProjectLiveInstantDiagnosticReason): void {
    const current = this.store.get(this.$instantDiagnostics);
    this.store.set(this.$instantDiagnostics, {
      ...current,
      rejectedMutationCount: current.rejectedMutationCount + 1,
      rejectionCounts: {
        ...current.rejectionCounts,
        [reason]: (current.rejectionCounts[reason] ?? 0) + 1,
      },
      updatedAt: Date.now(),
    });
  }

  private diagnosticReasonFromDecision(reason: string): ProjectLiveInstantDiagnosticReason {
    if (reason.includes('preamble')) return 'preamble-change';
    if (reason.includes('multiple paragraphs') || reason.includes('paragraph structure')) return 'multiple-paragraphs';
    if (reason.includes('environment')) return 'environment-context';
    if (reason.includes('structural LaTeX') || reason.includes('paragraph boundary') || reason.includes('document tail')) return 'structural-edit';
    return 'other';
  }

  private scheduleInstantIdleDispose(): void {
    if (this.instantIdleTimer) clearTimeout(this.instantIdleTimer);
    this.instantIdleTimer = setTimeout(() => {
      this.instantIdleTimer = undefined;
      if (this.instantRunning || this.pendingInstant) return;
      this.compiler?.disposeInstant?.();
    }, INSTANT_IDLE_DISPOSE_MS);
  }

  private rememberViewerOutcome(generation: number, outcome: 'accepted' | 'rejected'): boolean {
    if (this.instantViewerOutcomes.has(generation)) return false;
    this.instantViewerOutcomes.set(generation, outcome);
    if (this.instantViewerOutcomes.size > 100) {
      const oldest = this.instantViewerOutcomes.keys().next().value as number | undefined;
      if (oldest !== undefined) this.instantViewerOutcomes.delete(oldest);
    }
    return true;
  }

  reportInstantPatchAccepted(generation: number): void {
    if (!this.rememberViewerOutcome(generation, 'accepted')) return;
    const current = this.store.get(this.$instantDiagnostics);
    this.updateInstantDiagnostics({ viewerAcceptedPatchCount: current.viewerAcceptedPatchCount + 1 });
  }

  reportInstantPatchRejected(generation: number, reason: 'line-count-change' | 'viewer-paint-failure'): void {
    if (!this.rememberViewerOutcome(generation, 'rejected')) return;
    const current = this.store.get(this.$instantDiagnostics);
    this.updateInstantDiagnostics({ viewerRejectedPatchCount: current.viewerRejectedPatchCount + 1 });
    this.recordInstantRejection(reason);
  }

  private warmInstantRuntime(): void {
    const compiler = this.compiler;
    if (!compiler?.warmInstant) return;
    const startedAt = performance.now();
    void compiler.warmInstant().then(() => {
      const current = this.store.get(this.$instantDiagnostics);
      this.updateInstantDiagnostics({
        warmupCount: current.warmupCount + 1,
        lastWarmupDurationMs: Math.max(0, performance.now() - startedAt),
      });
      this.scheduleInstantIdleDispose();
    }).catch(() => {
      const current = this.store.get(this.$instantDiagnostics);
      this.updateInstantDiagnostics({
        warmupFailureCount: current.warmupFailureCount + 1,
        workerFailureCount: current.workerFailureCount + 1,
        lastWarmupDurationMs: Math.max(0, performance.now() - startedAt),
      });
      this.scheduleInstantIdleDispose();
    });
  }

  private liveCompiler(): LatexLiveDraftCompiler {
    const compiler = this.compiler ?? this.config.createLiveDraftCompiler();
    this.compiler = compiler;
    return compiler;
  }

  private cancelProbe(): void {
    if (!this.probeRunning) return;
    this.probeRunning.abort(new DOMException('Live PDF runtime test cancelled', 'AbortError'));
    this.liveCompiler().cancel();
    this.probeRunning = undefined;
    this.generation += 1;
  }

  private cancelLiveRender(): void {
    if (!this.liveRunning) return;
    this.liveRunning.abort(new DOMException('Live PDF render superseded', 'AbortError'));
    this.compiler?.cancel();
    this.liveRunning = undefined;
  }

  /**
   * R4: certify the BusyTeX Lua runtime with a tiny Learnitive-owned document.
   * wsName is retained only for state/reporting compatibility; no manuscript
   * files are read or instrumented by this method.
   */
  async probe(wsName: string, signal?: AbortSignal): Promise<LatexLiveDraftProbeResult> {
    await this.mountPromise;
    this.cancelProbe();
    const generation = ++this.generation;
    const controller = new AbortController();
    this.probeRunning = controller;
    const relayAbort = () => controller.abort(signal?.reason);
    signal?.addEventListener('abort', relayAbort, { once: true });

    try {
      this.store.set(this.$state, { status: 'preparing', wsName });
      controller.signal.throwIfAborted();
      const compiler = this.liveCompiler();
      this.store.set(this.$state, { status: 'probing', wsName });
      const result = await compiler.probe({ signal: controller.signal });
      controller.signal.throwIfAborted();
      if (generation !== this.generation) {
        throw new DOMException('Live PDF runtime test superseded', 'AbortError');
      }
      if (result.ok) this.runtimeCertification = result;
      this.store.set(this.$state, {
        status: result.ok ? 'success' : 'error',
        wsName,
        result,
        ...(result.ok ? {} : { error: result.reason ?? 'Live PDF runtime test failed.' }),
      });
      return result;
    } catch (error) {
      const cancelled = controller.signal.aborted || generation !== this.generation;
      const message = error instanceof Error ? error.message : 'Live PDF runtime test failed.';
      if (generation === this.generation) {
        this.store.set(this.$state, {
          status: cancelled ? 'cancelled' : 'error',
          wsName,
          ...(cancelled ? {} : { error: message }),
        });
      }
      throw error;
    } finally {
      signal?.removeEventListener('abort', relayAbort);
      if (this.probeRunning === controller) this.probeRunning = undefined;
    }
  }

  private certificationIsValid(
    result: LatexLiveDraftProbeResult | undefined,
  ): result is LatexLiveDraftProbeResult & { ok: true; pdf: Uint8Array } {
    return Boolean(
      result?.ok &&
        result.runtime === 'busytex' &&
        result.engine === 'lualatex' &&
        result.directLua &&
        result.memfsRecords &&
        result.pageCount > 0 &&
        result.pdf?.byteLength,
    );
  }

  private async collectLiveSnapshot(wsName: string, signal: AbortSignal) {
    // Collaboration owns the durable snapshot barrier and already flushes the
    // LatexSource workspace in both collaborative and non-collaborative modes.
    // Do not flush the same workspace a second time on every Live PDF refresh.
    await this.dependencies.projectCollaboration.flushToStorage(wsName);
    if (this.dependencies.latexSource.hasPendingOrFailedSaveInWorkspace(wsName)) {
      throw new Error('Live PDF compile blocked because a LaTeX source file could not be saved safely.');
    }
    signal.throwIfAborted();
    const [projectConfig, storage] = await Promise.all([
      this.dependencies.projectStorage.getProjectConfig(wsName),
      this.dependencies.projectStorage.getProvider(wsName),
    ]);
    const snapshot = await collectProjectCompileFiles(storage, projectConfig.rootFile, signal);
    return { projectConfig, snapshot };
  }

  private rememberLiveBaseline(
    wsName: string,
    generation: number,
    snapshot: Awaited<ReturnType<typeof collectProjectCompileFiles>>,
  ): void {
    const files = snapshot.files.map((file) => ({ path: file.path, content: file.content.slice() }));
    const textByPath = new Map<string, string>();
    for (const file of files) {
      const path = String(file.path);
      if (LIVE_SOURCE_PATH.test(path)) textByPath.set(path, liveSourceDecoder.decode(file.content));
    }
    this.liveBaselines.set(wsName, {
      generation,
      rootFile: snapshot.rootFile,
      files,
      textByPath,
    });
    this.dirtyPaths.delete(wsName);
  }

  private clearInstantPatch(reason?: string): void {
    const current = this.store.get(this.$livePdfState);
    if (!current.instantPatch && current.instantStatus !== 'rendering' && !current.instantReason) return;
    if (current.instantPatch) {
      this.updateInstantDiagnostics({ lastPatchLifetimeMs: Math.max(0, Date.now() - current.instantPatch.createdAt) });
    }
    this.store.set(this.$livePdfState, {
      ...current,
      instantStatus: 'idle',
      instantPatch: undefined,
      ...(reason ? { instantReason: reason } : { instantReason: undefined }),
    });
  }

  private cancelInstantRender(): void {
    this.instantGeneration += 1;
    if (this.instantTimer) clearTimeout(this.instantTimer);
    this.instantTimer = undefined;
    this.pendingInstant = undefined;
    if (this.instantRunning) {
      this.instantRunning.controller.abort(new DOMException('Instant Live PDF render cancelled', 'AbortError'));
      this.compiler?.cancelInstant?.();
      this.instantRunning = undefined;
    }
  }

  private scheduleInstantPdf(event: LatexSourceMutationEvent): void {
    if (this.instantIdleTimer) clearTimeout(this.instantIdleTimer);
    this.instantIdleTimer = undefined;
    const diagnostics = this.store.get(this.$instantDiagnostics);
    this.updateInstantDiagnostics({ mutationCount: diagnostics.mutationCount + 1 });
    const live = this.store.get(this.$livePdfState);
    const compiler = this.compiler;
    const sourcePath = WsPath.assertFile(event.wsPath).filePath;
    const rejection: ProjectLiveInstantDiagnosticReason | undefined =
      this.config.instantLivePdf === false || !compiler?.renderInstant || !live.generation
        ? 'unsupported-runtime'
        : live.backend !== 'busytex'
          ? 'cloud-backend'
          : !LIVE_SOURCE_PATH.test(sourcePath)
            ? 'unsupported-file'
            : !liveInstantOriginEligible(event.origin)
              ? 'unsupported-origin'
              : event.edits?.length !== 1
                ? 'multiple-edits'
                : undefined;
    if (rejection) {
      this.recordInstantRejection(rejection);
      this.pendingInstant = undefined;
      if (this.instantTimer) clearTimeout(this.instantTimer);
      this.instantTimer = undefined;
      this.clearInstantPatch('R6 skipped this edit; exact R5 reconciliation remains active.');
      return;
    }

    const token = ++this.instantGeneration;
    this.pendingInstant = { event, token, queuedAt: performance.now() };
    if (this.instantTimer) clearTimeout(this.instantTimer);
    if (this.instantRunning) return;
    this.instantTimer = setTimeout(() => {
      this.instantTimer = undefined;
      void this.drainInstantPdf();
    }, INSTANT_EDIT_DEBOUNCE_MS);
  }

  private async drainInstantPdf(): Promise<void> {
    if (this.instantRunning) return;
    const pending = this.pendingInstant;
    this.pendingInstant = undefined;
    if (!pending) return;
    const { event, token, queuedAt } = pending;
    const live = this.store.get(this.$livePdfState);
    const compiler = this.compiler;
    const sourceFile = WsPath.assertFile(event.wsPath);
    const sourcePath = sourceFile.filePath;
    const baseline = this.liveBaselines.get(event.wsName);
    const dirty = this.dirtyPaths.get(event.wsName);
    const source = this.dependencies.latexSource.getSnapshot(event.wsPath);
    const pdfState = this.dependencies.projectPdf.getWorkspaceState(event.wsName);

    if (
      token !== this.instantGeneration ||
      !compiler?.renderInstant ||
      !live.enabled ||
      !live.certified ||
      live.backend !== 'busytex' ||
      live.status !== 'ready' ||
      !live.generation ||
      !baseline ||
      baseline.generation !== live.generation ||
      !source ||
      source.revision !== event.revision ||
      source.externalConflict ||
      dirty?.size !== 1 ||
      !dirty.has(sourcePath) ||
      pdfState?.origin !== 'live' ||
      pdfState.generation !== baseline.generation
    ) {
      const reason: ProjectLiveInstantDiagnosticReason = token !== this.instantGeneration
        ? 'stale-generation'
        : source?.externalConflict
          ? 'source-conflict'
          : dirty && dirty.size > 1
            ? 'multiple-files'
            : !baseline || baseline.generation !== live.generation || pdfState?.origin !== 'live' || pdfState.generation !== baseline?.generation
              ? 'no-r5-baseline'
              : 'other';
      this.recordInstantRejection(reason);
      this.clearInstantPatch('R6 baseline was not exact; using R5.');
      return;
    }

    const baselineText = baseline.textByPath.get(sourcePath);
    if (baselineText === undefined) {
      this.recordInstantRejection('no-r5-baseline');
      this.clearInstantPatch('R6 source was not present in the exact baseline.');
      return;
    }
    const decision = decideLiveInstantParagraph(baselineText, source.text);
    if (decision.kind !== 'patch') {
      this.recordInstantRejection(this.diagnosticReasonFromDecision(decision.reason));
      this.clearInstantPatch(decision.reason);
      return;
    }
    const mappings = this.dependencies.projectPdf.getMappingsForFile(
      event.wsName,
      projectFilePath(sourcePath),
    );
    const target = selectLiveInstantTarget(
      mappings,
      decision.paragraph.startLine,
      decision.paragraph.endLine,
    );
    if (!target) {
      this.recordInstantRejection('synctex-target');
      this.clearInstantPatch('R6 could not prove a single-page SyncTeX target.');
      return;
    }

    const eligibleDiagnostics = this.store.get(this.$instantDiagnostics);
    this.updateInstantDiagnostics({ eligibleMutationCount: eligibleDiagnostics.eligibleMutationCount + 1 });

    const files = baseline.files.map((file) =>
      String(file.path) === sourcePath
        ? { path: file.path, content: liveSourceEncoder.encode(source.text) }
        : { path: file.path, content: file.content.slice() },
    );
    const controller = new AbortController();
    this.instantRunning = { controller, token };
    this.store.set(this.$livePdfState, {
      ...live,
      instantStatus: 'rendering',
      instantReason: undefined,
    });

    try {
      const result = await compiler.renderInstant({
        rootFile: baseline.rootFile,
        files,
        text: decision.paragraph.text,
        hsize: target.hsize,
        signal: controller.signal,
      });
      controller.signal.throwIfAborted();
      const latest = this.store.get(this.$livePdfState);
      const latestSource = this.dependencies.latexSource.getSnapshot(event.wsPath);
      if (
        token !== this.instantGeneration ||
        latestSource?.revision !== event.revision ||
        latest.generation !== baseline.generation ||
        !latest.enabled ||
        latest.backend !== 'busytex'
      ) return;
      if (!result.ok || !result.pdf?.byteLength) {
        const failureDiagnostics = this.store.get(this.$instantDiagnostics);
        this.recordInstantRejection('compiler-failure');
        this.updateInstantDiagnostics({ workerFailureCount: failureDiagnostics.workerFailureCount + 1, lastBlockCompileDurationMs: result.durationMs });
        this.clearInstantPatch(result.reason ?? 'R6 block render failed; using R5.');
        return;
      }
      const renderedDiagnostics = this.store.get(this.$instantDiagnostics);
      this.updateInstantDiagnostics({
        renderedPatchCount: renderedDiagnostics.renderedPatchCount + 1,
        lastBlockCompileDurationMs: result.durationMs,
        lastPublishLatencyMs: Math.max(0, performance.now() - queuedAt),
      });
      this.store.set(this.$livePdfState, {
        ...latest,
        instantStatus: 'ready',
        instantReason: undefined,
        instantPatch: {
          generation: token,
          baseGeneration: baseline.generation,
          sourcePath,
          sourceRevision: event.revision,
          target,
          pdf: result.pdf,
          durationMs: result.durationMs,
          createdAt: Date.now(),
        },
      });
    } catch (error) {
      if (!(error instanceof DOMException && error.name === 'AbortError')) {
        const failureDiagnostics = this.store.get(this.$instantDiagnostics);
        this.recordInstantRejection('compiler-failure');
        this.updateInstantDiagnostics({ workerFailureCount: failureDiagnostics.workerFailureCount + 1 });
        this.clearInstantPatch(error instanceof Error ? error.message : 'R6 block render failed; using R5.');
      }
    } finally {
      if (this.instantRunning?.token === token) this.instantRunning = undefined;
      if (this.pendingInstant && !this.instantTimer) {
        this.instantTimer = setTimeout(() => {
          this.instantTimer = undefined;
          void this.drainInstantPdf();
        }, 0);
      } else if (!this.pendingInstant) {
        this.scheduleInstantIdleDispose();
      }
    }
  }

  private async canUseCloudFallback(engine: 'pdflatex' | 'xelatex' | 'lualatex'): Promise<boolean> {
    const cloud = await this.dependencies.projectEntitlements.cloudCompileCapabilities();
    return cloud.freeFallbackEnabled === true && cloud.allowedEngines.includes(engine);
  }

  private async rememberCloudFallback(wsName: string): Promise<boolean> {
    rememberSessionCloudFallback(wsName);
    try {
      await this.dependencies.projectStorage.updateProjectConfig(wsName, (current) => ({
        ...current,
        cloudFallbackSticky: true,
      }));
      return true;
    } catch {
      // The shared session marker still keeps Live PDF and normal Compile on
      // Docker for this project until the user explicitly retries the browser.
      return true;
    }
  }

  private async renderCloudLivePdf(
    wsName: string,
    generation: number,
    previous: ProjectLivePdfState,
    controller: AbortController,
    projectConfig: Awaited<ReturnType<ProjectStorageService['getProjectConfig']>>,
    snapshot: Awaited<ReturnType<typeof collectProjectCompileFiles>>,
    reason: string,
    rememberPreference: boolean,
  ): Promise<ProjectLivePdfState> {
    const startedAt = performance.now();
    this.liveBaselines.delete(wsName);
    this.dirtyPaths.delete(wsName);
    this.clearInstantPatch('R6 is browser-local; Docker Live PDF uses exact R5 reconciliation.');
    const cloud = await this.dependencies.projectEntitlements.cloudCompile({
      engine: projectConfig.compiler,
      rootFile: snapshot.rootFile,
      files: snapshot.files,
      signal: controller.signal,
      fallback: true,
    });
    controller.signal.throwIfAborted();
    const cloudSucceeded = cloud.success === true && Boolean(cloud.pdf?.byteLength);
    const remembered = rememberPreference && cloudSucceeded
      ? await this.rememberCloudFallback(wsName)
      : projectConfig.cloudFallbackSticky === true || hasSessionCloudFallback(wsName);
    if (generation !== this.liveGeneration) {
      throw new DOMException('Live PDF render superseded', 'AbortError');
    }
    const log = [
      '===== Learnitive Live PDF · Docker TeX Live Full =====',
      `Cloud fallback is active for this project (${reason}).`,
      cloudSucceeded
        ? remembered
          ? 'Future Live PDF and Compile requests for this project will continue using Docker TeX Live Full.'
          : 'This Live PDF generation used Docker TeX Live Full, but the project preference could not be retained.'
        : 'Docker TeX Live Full did not produce a PDF, so the project fallback preference was not changed.',
      '=================================================',
      '',
      cloud.log,
    ].join('\n');
    if (!cloud.success || !cloud.pdf?.byteLength) {
      const state: ProjectLivePdfState = {
        ...previous,
        status: 'error',
        wsName,
        enabled: true,
        certified: true,
        backend: 'cloud',
        error: cloud.errors?.find((item) => item.message)?.message ?? 'Docker TeX Live Full Live PDF compile failed.',
        log,
        durationMs: Math.max(0, performance.now() - startedAt),
      };
      this.store.set(this.$livePdfState, state);
      return state;
    }
    const state: ProjectLivePdfState = {
      status: 'ready',
      wsName,
      enabled: true,
      certified: true,
      backend: 'cloud',
      pdf: cloud.pdf,
      ...(cloud.synctex?.byteLength ? { synctex: cloud.synctex } : {}),
      rootFile: String(snapshot.rootFile),
      generation,
      log,
      durationMs: Math.max(0, performance.now() - startedAt),
    };
    this.store.set(this.$livePdfState, state);
    return state;
  }

  private async renderLivePdf(
    wsName: string,
    generation: number,
    previous: ProjectLivePdfState,
    forcedCloudReason?: string,
  ): Promise<ProjectLivePdfState> {
    this.cancelLiveRender();
    this.cancelInstantRender();
    const controller = new AbortController();
    this.liveRunning = controller;
    this.store.set(this.$livePdfState, {
      ...previous,
      status: 'rendering',
      wsName,
      enabled: true,
      certified: true,
      error: undefined,
    });
    try {
      const { projectConfig, snapshot } = await this.collectLiveSnapshot(wsName, controller.signal);
      controller.signal.throwIfAborted();
      if (forcedCloudReason) {
        return this.renderCloudLivePdf(
          wsName,
          generation,
          previous,
          controller,
          projectConfig,
          snapshot,
          forcedCloudReason,
          true,
        );
      }
      if (projectConfig.cloudFallbackSticky === true || hasSessionCloudFallback(wsName)) {
        return this.renderCloudLivePdf(
          wsName,
          generation,
          previous,
          controller,
          projectConfig,
          snapshot,
          'remembered for this project',
          projectConfig.cloudFallbackSticky !== true,
        );
      }
      let result: LatexLiveDraftRenderResult;
      try {
        result = await this.liveCompiler().render({
          rootFile: snapshot.rootFile,
          files: snapshot.files,
          signal: controller.signal,
        });
      } catch (renderError) {
        controller.signal.throwIfAborted();
        const message = renderError instanceof Error ? renderError.message : String(renderError);
        const fallbackReason = fullTexliveFallbackReasonFromText(message);
        if (fallbackReason && await this.canUseCloudFallback(projectConfig.compiler)) {
          return this.renderCloudLivePdf(
            wsName,
            generation,
            previous,
            controller,
            projectConfig,
            snapshot,
            fallbackReason,
            true,
          );
        }
        throw renderError;
      }
      controller.signal.throwIfAborted();
      if (generation !== this.liveGeneration) {
        throw new DOMException('Live PDF render superseded', 'AbortError');
      }
      if (!result.ok || !result.pdf?.byteLength) {
        const fallbackReason = fullTexliveFallbackReasonFromText(
          [result.reason, result.log].filter(Boolean).join('\n'),
        );
        if (fallbackReason && await this.canUseCloudFallback(projectConfig.compiler)) {
          return this.renderCloudLivePdf(
            wsName,
            generation,
            previous,
            controller,
            projectConfig,
            snapshot,
            fallbackReason,
            true,
          );
        }
        const state: ProjectLivePdfState = {
          ...previous,
          status: 'error',
          wsName,
          enabled: true,
          certified: true,
          backend: 'busytex',
          error: result.reason ?? 'Live PDF compile failed.',
          log: result.log,
          durationMs: result.durationMs,
          instantStatus: 'idle',
          instantPatch: undefined,
        };
        this.store.set(this.$livePdfState, state);
        return state;
      }
      this.rememberLiveBaseline(wsName, generation, snapshot);
      const state: ProjectLivePdfState = {
        status: 'ready',
        wsName,
        enabled: true,
        certified: true,
        backend: 'busytex',
        instantStatus: 'idle',
        ...(previous.instantPatch ? { instantPatch: previous.instantPatch } : {}),
        pdf: result.pdf,
        ...(result.synctex?.byteLength ? { synctex: result.synctex } : {}),
        rootFile: String(snapshot.rootFile),
        generation,
        log: result.log,
        durationMs: result.durationMs,
        ...(previous.certificate ? { certificate: previous.certificate } : {}),
      };
      this.store.set(this.$livePdfState, state);
      // Start the isolated R6 runtime only after an exact browser PDF exists.
      // Warm-up is opportunistic: failure is invisible and R5 remains complete.
      this.warmInstantRuntime();
      return state;
    } catch (error) {
      if (generation !== this.liveGeneration) throw error;
      const cancelled = controller.signal.aborted;
      if (cancelled) throw error;
      const message = error instanceof Error ? error.message : 'Live PDF compile failed.';
      const state: ProjectLivePdfState = {
        ...previous,
        status: 'error',
        wsName,
        enabled: true,
        certified: true,
        error: message,
        instantStatus: 'idle',
        instantPatch: undefined,
      };
      this.store.set(this.$livePdfState, state);
      return state;
    } finally {
      if (this.liveRunning === controller) this.liveRunning = undefined;
    }
  }

  /**
   * R5: certify once, then compile the real canonical root directly with
   * BusyTeX LuaLaTeX. The user's manuscript is never wrapped in the R4 probe.
   */
  async enableLivePdf(wsName: string): Promise<ProjectLivePdfState> {
    await this.mountPromise;
    const generation = ++this.liveGeneration;
    if (this.liveTimer) clearTimeout(this.liveTimer);
    this.liveTimer = undefined;
    this.cancelLiveRender();
    const previous = this.store.get(this.$livePdfState);
    const previousPdf = previous.wsName === wsName && previous.pdf?.byteLength ? previous.pdf : undefined;
    const projectConfig = await this.dependencies.projectStorage.getProjectConfig(wsName);
    if (projectConfig.cloudFallbackSticky === true || hasSessionCloudFallback(wsName)) {
      const cloudBase: ProjectLivePdfState = {
        ...(previousPdf ? { pdf: previousPdf } : {}),
        status: 'rendering',
        wsName,
        enabled: true,
        certified: true,
        backend: 'cloud',
      };
      this.store.set(this.$livePdfState, cloudBase);
      return this.renderLivePdf(wsName, generation, cloudBase);
    }
    this.store.set(this.$livePdfState, {
      ...(previousPdf ? { pdf: previousPdf } : {}),
      status: 'certifying',
      wsName,
      enabled: false,
      certified: false,
      backend: 'busytex',
    });
    try {
      const certification = this.certificationIsValid(this.runtimeCertification)
        ? this.runtimeCertification
        : await this.probe(wsName);
      if (generation !== this.liveGeneration) {
        throw new DOMException('Live PDF certification superseded', 'AbortError');
      }
      if (!this.certificationIsValid(certification)) {
        if (await this.canUseCloudFallback(projectConfig.compiler)) {
          const cloudBase: ProjectLivePdfState = {
            ...(previousPdf ? { pdf: previousPdf } : {}),
            status: 'rendering',
            wsName,
            enabled: true,
            certified: true,
            backend: 'cloud',
            log: certification.log,
            certificate: certification.certificate,
          };
          this.store.set(this.$livePdfState, cloudBase);
          return this.renderLivePdf(
            wsName,
            generation,
            cloudBase,
            'browser Live PDF runtime certification failed',
          );
        }
        const state: ProjectLivePdfState = {
          ...(previousPdf ? { pdf: previousPdf } : {}),
          status: 'error',
          wsName,
          enabled: false,
          certified: false,
          error: certification.reason ?? 'Live PDF runtime test failed.',
          log: certification.log,
          certificate: certification.certificate,
        };
        this.store.set(this.$livePdfState, state);
        return state;
      }
      // Pre-warm the isolated R6 LuaLaTeX lane while the exact first R5
      // manuscript compile is running. It is a separate worker, so failure or
      // cancellation cannot affect certification or the authoritative PDF.
      this.warmInstantRuntime();
      const certifiedBase: ProjectLivePdfState = {
        ...(previousPdf ? { pdf: previousPdf } : {}),
        status: 'rendering',
        wsName,
        enabled: true,
        certified: true,
        backend: 'busytex',
        log: certification.log,
        certificate: certification.certificate,
      };
      return this.renderLivePdf(wsName, generation, certifiedBase);
    } catch (error) {
      if (generation !== this.liveGeneration) throw error;
      if (await this.canUseCloudFallback(projectConfig.compiler)) {
        const cloudBase: ProjectLivePdfState = {
          ...(previousPdf ? { pdf: previousPdf } : {}),
          status: 'rendering',
          wsName,
          enabled: true,
          certified: true,
          backend: 'cloud',
        };
        this.store.set(this.$livePdfState, cloudBase);
        return this.renderLivePdf(
          wsName,
          generation,
          cloudBase,
          'browser Live PDF runtime certification failed',
        );
      }
      const message = error instanceof Error ? error.message : 'Live PDF runtime test failed.';
      const state: ProjectLivePdfState = {
        ...(previousPdf ? { pdf: previousPdf } : {}),
        status: 'error',
        wsName,
        enabled: false,
        certified: false,
        error: message,
      };
      this.store.set(this.$livePdfState, state);
      return state;
    }
  }

  disableLivePdf(wsName?: string): void {
    const current = this.store.get(this.$livePdfState);
    if (wsName && current.wsName && current.wsName !== wsName) return;
    this.liveGeneration += 1;
    if (this.liveTimer) clearTimeout(this.liveTimer);
    this.liveTimer = undefined;
    this.cancelLiveRender();
    this.cancelInstantRender();
    if (this.instantIdleTimer) clearTimeout(this.instantIdleTimer);
    this.instantIdleTimer = undefined;
    this.compiler?.disposeInstant?.();
    const targetWs = wsName ?? current.wsName;
    if (targetWs) {
      this.liveBaselines.delete(targetWs);
      this.dirtyPaths.delete(targetWs);
    }
    this.store.set(this.$livePdfState, {
      status: 'disabled',
      ...(current.wsName ? { wsName: current.wsName } : {}),
      enabled: false,
      certified: false,
    });
  }

  private scheduleLivePdf(wsName: string): void {
    if (this.liveTimer) clearTimeout(this.liveTimer);
    // If the author resumes typing, stop an obsolete compile immediately
    // instead of letting it compete for CPU until the next debounce expires.
    this.cancelLiveRender();
    // Compilation is never part of the keystroke path. A slightly longer idle
    // window prevents complex projects from repeatedly compiling between normal
    // pauses while still keeping Live PDF responsive after the author stops.
    const delayMs = this.store.get(this.$livePdfState).backend === 'cloud' ? 1_300 : 800;
    this.liveTimer = setTimeout(() => {
      this.liveTimer = undefined;
      void this.refreshLivePdf(wsName);
    }, delayMs);
  }

  private async refreshLivePdf(wsName: string): Promise<void> {
    const current = this.store.get(this.$livePdfState);
    if (!current.enabled || !current.certified || current.wsName !== wsName) return;
    const generation = ++this.liveGeneration;
    try {
      await this.renderLivePdf(wsName, generation, current);
    } catch (error) {
      if (generation !== this.liveGeneration) return;
      if (error instanceof DOMException && error.name === 'AbortError') return;
      const message = error instanceof Error ? error.message : 'Live PDF compile failed.';
      this.store.set(this.$livePdfState, {
        ...current,
        status: 'error',
        enabled: true,
        certified: true,
        error: message,
        instantStatus: 'idle',
        instantPatch: undefined,
      });
    }
  }

  cancel(): void {
    const hadProbe = Boolean(this.probeRunning);
    this.cancelProbe();
    this.cancelLiveRender();
    this.cancelInstantRender();
    if (!hadProbe && this.compiler) this.compiler.cancel();
    const current = this.store.get(this.$state);
    if (current.status === 'preparing' || current.status === 'probing') {
      this.store.set(this.$state, { ...current, status: 'cancelled' });
    }
  }
}
