import 'fake-indexeddb/auto';
import { WORKSPACE_STORAGE_TYPE } from '@learnitive/constants';
import { LOCAL_AI_MODELS, type GenerationRequest, type LocalAiModelId, type LocalAiPlatform } from '@learnitive/project-ai';
import type { LatexCompileRequest, LatexCompileResult, LatexCompiler } from '@learnitive/latex-compiler';
import { projectFilePath } from '@learnitive/project-storage';
import { createTestEnvironment } from '@learnitive/test-utils';
import { describe, expect, it, vi } from 'vitest';

type FakePlatform = LocalAiPlatform & { responses: string[]; generations: GenerationRequest[]; dispose(): void };

class ReviewTestCompiler implements LatexCompiler {
  readonly backend = 'busytex' as const;
  readonly requests: LatexCompileRequest[] = [];
  supports(): boolean { return true; }
  async initialize(): Promise<void> {}
  async compile(request: LatexCompileRequest): Promise<LatexCompileResult> {
    this.requests.push(request);
    return {
      success: true,
      backend: this.backend,
      compiler: request.compiler,
      exitCode: 0,
      pdf: new Uint8Array([37, 80, 68, 70]),
      log: 'ok',
      diagnostics: [],
      durationMs: 5,
      bibliography: { bibtex: false, biberRequested: false, makeindex: false, rerun: false },
    };
  }
  cancel(): void {}
  dispose(): void {}
}

function fakeLocalAi(responses: string[], delayMs = 0, initiallyInstalled = true): FakePlatform {
  const generations: GenerationRequest[] = [];
  let installed = initiallyInstalled;
  return {
    models: LOCAL_AI_MODELS,
    responses: [...responses],
    generations,
    capabilities: async () => ({ webGpu: true, opfs: true, supported: true }),
    modelState: async (modelId) => installed
      ? ({ modelId, status: 'installed' as const, installedBytes: 1024 })
      : ({ modelId, status: 'not-installed' as const }),
    downloadModel: async (modelId, onProgress) => {
      installed = true;
      const state = { modelId, status: 'installed' as const, installedBytes: 1024 };
      onProgress(state);
      return state;
    },
    removeModel: async () => { installed = false; },
    createGenerationEngine: (_modelId: LocalAiModelId) => ({
      load: async () => undefined,
      unload: async () => undefined,
      contextWindowTokens: () => 8192,
      async *generate(request, signal) {
        generations.push(request);
        if (delayMs > 0) {
          await new Promise<void>((resolve, reject) => {
            const timer = setTimeout(resolve, delayMs);
            const onAbort = () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); };
            signal?.addEventListener('abort', onAbort, { once: true });
            if (signal?.aborted) onAbort();
          });
        }
        signal?.throwIfAborted();
        yield { text: responses.shift() ?? '{"findings":[]}' };
      },
    }),
    dispose() {},
  };
}

async function setup(
  responses: string[],
  source = '\\documentclass{article}\n\\begin{document}\nThis is really very important for the results.\n\\end{document}\n',
  delayMs = 0,
  compiler?: LatexCompiler,
  localModelInstalled = true,
) {
  const platform = fakeLocalAi(responses, delayMs, localModelInstalled);
  const env = createTestEnvironment({
    coreConfigOverrides: {
      projectAi: () => ({ createLocalAiPlatform: () => platform }),
      ...(compiler ? { projectCompile: () => ({ createCompiler: () => compiler }) } : {}),
    },
  });
  const services = env.instantiateAll();
  await env.mountAll();
  const name = `ai-writing-${crypto.randomUUID()}`;
  await services.workspaceOps.createWorkspaceInfo({ name, type: WORKSPACE_STORAGE_TYPE.Memory, metadata: {} });
  await services.fileSystem.createTextFile(`${name}:main.tex`, source);
  await services.latexSource.load(`${name}:main.tex`);
  services.projectAi.configure({
    enabled: true,
    provider: 'local',
    localModelId: LOCAL_AI_MODELS[0]!.id,
    model: '',
    rememberApiKey: false,
    privacyMode: true,
    autoExplainErrors: false,
  });
  await services.projectAi.refreshLocalAiState();
  return { env, services, name, source, platform };
}


async function overwriteReviewCheckpoint(projectId: string, value: unknown): Promise<void> {
  const db = await new Promise<IDBDatabase>((resolve, reject) => {
    const request = indexedDB.open('interactive-ai-writing-v1', 1);
    request.onupgradeneeded = () => {
      if (!request.result.objectStoreNames.contains('paper-review-runs')) request.result.createObjectStore('paper-review-runs');
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
  await new Promise<void>((resolve, reject) => {
    const request = db.transaction('paper-review-runs', 'readwrite').objectStore('paper-review-runs').put(value, projectId);
    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
  });
  db.close();
}

describe('ProjectAiWritingService', () => {
  it('routes a manual Local AI writing proposal into Review without mutating canonical source', async () => {
    const response = JSON.stringify({ replacement: 'This is important for interpreting the results.', category: 'writing', reason: 'Remove informal intensifiers.', confidence: 0.94 });
    const { env, services, name, source, platform } = await setup([response]);
    const from = source.indexOf('This is');
    const to = source.indexOf('\n', from);
    const suggestion = await services.projectAiWriting.suggestSelection(name, { filePath: 'main.tex', from, to, action: 'improve-writing' });

    expect(suggestion?.replacement).toBe('This is important for interpreting the results.');
    expect(platform.generations).toHaveLength(1);
    expect(services.latexSource.getSnapshot(`${name}:main.tex`)?.text).toBe(source);
    const change = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' })[0];
    expect(change?.author.kind).toBe('ai');
    expect(change?.metadata).toMatchObject({ source: 'ai', category: 'writing', provider: 'local' });
    env.controller.abort();
  });

  it('repairs raw LaTeX backslashes in structured AI Suggest JSON without corrupting commands', async () => {
    const source = String.raw`\documentclass{article}
\begin{document}
\begin{equation}\label{eq:main}x=1\end{equation}
The \textbf{method} follows Eq.~\ref{eq:main} and prior work~\cite{real2026}.
\end{document}
`;
    const response = String.raw`{"replacement":"The \textbf{method} improves the explanation of Eq.~\ref{eq:main} and prior work~\cite{real2026}.","category":"writing","reason":"Clarify \textbf{wording} without changing evidence.","confidence":0.91}`;
    const { env, services, name } = await setup([response], source);
    await services.fileSystem.createTextFile(`${name}:references.bib`, '@article{real2026, title={A Real Paper}, author={Researcher, A.}, year={2026}}\n');
    const from = source.indexOf('The \\textbf');
    const to = source.indexOf('\n', from);

    const suggestion = await services.projectAiWriting.suggestSelection(name, { filePath: 'main.tex', from, to, action: 'improve-writing' });

    expect(suggestion?.replacement).toBe(String.raw`The \textbf{method} improves the explanation of Eq.~\ref{eq:main} and prior work~\cite{real2026}.`);
    expect([...(suggestion?.replacement ?? '')].some((char) => [8, 9, 12, 13].includes(char.charCodeAt(0)))).toBe(false);
    expect(services.latexSource.getSnapshot(`${name}:main.tex`)?.text).toBe(source);
    env.controller.abort();
  });

  it('uses the Local AI sentinel contract for LaTeX-heavy source without JSON escaping', async () => {
    const source = String.raw`\documentclass{article}
\begin{document}
\begin{equation}\label{eq:main}x=1\end{equation}
The \textbf{method} follows Eq.~\ref{eq:main} and prior work~\cite{real2026}.
\end{document}
`;
    const response = String.raw`[LEARNITIVE_REPLACEMENT_BEGIN]
The \textbf{method} clearly follows Eq.~\ref{eq:main} and prior work~\cite{real2026}.
[LEARNITIVE_REPLACEMENT_END]
[LEARNITIVE_CATEGORY] clarity
[LEARNITIVE_CONFIDENCE] 0.93
[LEARNITIVE_REASON] Clarify the sentence while preserving LaTeX commands.
[LEARNITIVE_END]`;
    const { env, services, name, platform } = await setup([response], source);
    await services.fileSystem.createTextFile(`${name}:references.bib`, '@article{real2026, title={A Real Paper}, author={Researcher, A.}, year={2026}}\n');
    const from = source.indexOf('The \\textbf');
    const to = source.indexOf('\n', from);

    const suggestion = await services.projectAiWriting.suggestSelection(name, { filePath: 'main.tex', from, to, action: 'clarify' });

    expect(suggestion?.replacement).toBe(String.raw`The \textbf{method} clearly follows Eq.~\ref{eq:main} and prior work~\cite{real2026}.`);
    expect(platform.generations[0]?.messages[0]?.content).toContain('Return ONLY the replacement source text');
    expect(platform.generations[0]?.messages[0]?.content).toContain('Never output [LEARNITIVE_END]');
    expect(services.latexSource.getSnapshot(`${name}:main.tex`)?.text).toBe(source);
    env.controller.abort();
  });

  it('accepts plain Local AI replacement text and strips leaked Learnitive protocol markers', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThis sentence are unclear.\n\\end{document}\n';
    const response = 'This sentence is clear.\n[LEARNITIVE_END]';
    const { env, services, name } = await setup([response], source);
    const from = source.indexOf('This sentence');
    const to = source.indexOf('\n', from);

    const suggestion = await services.projectAiWriting.suggestSelection(name, { filePath: 'main.tex', from, to, action: 'improve-writing' });

    expect(suggestion?.replacement).toBe('This sentence is clear.');
    expect(suggestion?.replacement).not.toContain('LEARNITIVE_END');
    env.controller.abort();
  });

  it('rejects model output containing a citation key that is not grounded in the project bibliography', async () => {
    const response = JSON.stringify({ replacement: 'This result is important~\\cite{invented2026}.', category: 'writing', reason: 'Add support.', confidence: 0.92 });
    const { env, services, name, source } = await setup([response]);
    await services.fileSystem.createTextFile(`${name}:references.bib`, '@article{real2026, title={A Real Paper}, author={Researcher, A.}, year={2026}}\n');
    const from = source.indexOf('This is');
    const to = source.indexOf('\n', from);

    await expect(services.projectAiWriting.suggestSelection(name, { filePath: 'main.tex', from, to, action: 'improve-writing' })).rejects.toThrow(/unsafe|invalid/u);
    expect(services.projectReview.getChanges(name, { status: 'pending' })).toHaveLength(0);
    env.controller.abort();
  });

  it('normalizes BibTeX title acronyms deterministically without invoking a model', async () => {
    const { env, services, name, platform } = await setup([]);
    const bib = '@article{learnitive2026,\n  title = {latex and ai for pdf workflows},\n  author = {Author, A.},\n  year = {2026}\n}\n';
    await services.fileSystem.createTextFile(`${name}:references.bib`, bib);
    await services.latexSource.load(`${name}:references.bib`);
    const from = bib.indexOf('title');
    const to = bib.indexOf('\n', from);
    const suggestion = await services.projectAiWriting.suggestSelection(name, { filePath: 'references.bib', from, to, action: 'improve-writing' });

    expect(suggestion?.replacement).toContain('{LaTeX}');
    expect(suggestion?.replacement).toContain('{AI}');
    expect(platform.generations).toHaveLength(0);
    expect(services.latexSource.getSnapshot(`${name}:references.bib`)?.text).toBe(bib);
    env.controller.abort();
  });


  it('proposes a compiler-aware missing-image fix only from one verified project asset and never auto-applies it', async () => {
    const source = String.raw`\documentclass{article}
\usepackage{graphicx}
\begin{document}
\includegraphics{results-final}
\end{document}
`;
    const { env, services, name, platform } = await setup([], source);
    await services.fileSystem.createTextFile(`${name}:results.pdf`, 'fixture');
    const from = source.indexOf('results-final');
    const to = from + 'results-final'.length;
    const suggestion = await services.projectAiWriting.suggestSelection(name, { filePath: 'main.tex', from, to, action: 'improve-latex' });

    expect(suggestion?.replacement).toBe('results');
    expect(suggestion?.category).toBe('latex');
    expect(suggestion?.reason).toMatch(/single verified project asset/u);
    expect(platform.generations).toHaveLength(0);
    expect(services.latexSource.getSnapshot(`${name}:main.tex`)?.text).toBe(source);
    expect(services.projectReview.getChanges(name, { status: 'pending' })).toHaveLength(1);
    env.controller.abort();
  });

  it('creates an automatic deterministic LaTeX typo suggestion after idle without mutating source', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\n\\begin{figre}\nCaption text.\n\\end{figre}\n\\end{document}\n';
    const { env, services, name } = await setup([], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('figre') + 2;
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));

    const pending = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
    expect(pending).toHaveLength(1);
    expect(pending[0]?.metadata).toMatchObject({ source: 'ai', automatic: true, category: 'latex' });
    expect(pending[0]?.proposedText).toContain('\\begin{figure}');
    expect(services.latexSource.getSnapshot(`${name}:main.tex`)?.text).toBe(source);
    env.controller.abort();
  });


  it('uses `..?` as a direct AI Setup force command and reviews exactly the previous source line', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe experimental results demonstrates a consistent improvement.\n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'The experimental results demonstrate a consistent improvement.', category: 'grammar', reason: 'Fix subject-verb agreement.', confidence: 0.96 });
    const { env, services, name, platform } = await setup([response], source);
    const wsPath = `${name}:main.tex`;
    const snapshot = services.latexSource.getSnapshot(wsPath)!;
    const cursor = snapshot.text.indexOf('\n\\end{document}') + 1;

    // Automatic Suggestions is intentionally left Off: `..?` is a direct
    // AI Setup command and must not depend on the idle watcher toggle.
    services.latexSource.applyEdit(wsPath, { from: cursor, to: cursor, text: '..?' }, 'source');
    await new Promise((resolve) => setTimeout(resolve, 180));

    expect(services.latexSource.getSnapshot(wsPath)?.text).toBe(source);
    expect(platform.generations).toHaveLength(1);
    const pending = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
    expect(pending).toHaveLength(1);
    expect(pending[0]?.originalText).toBe('The experimental results demonstrates a consistent improvement.');
    expect(pending[0]?.proposedText).toContain('results demonstrate');
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('suggested');
    expect(env.store.get(services.projectAiWriting.$lastSuggestion)).toMatchObject({
      wsName: name,
      filePath: 'main.tex',
      reviewThreadId: pending[0]?.id,
    });
    env.controller.abort();
  });

  it('keeps the explicit `..?` review armed when its delayed system-removal mutation is observed', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe experimental results demonstrates a consistent improvement.\n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'The experimental results demonstrate a consistent improvement.', category: 'grammar', reason: 'Fix subject-verb agreement.', confidence: 0.96 });
    const { env, services, name, platform } = await setup([response], source, 120);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const wsPath = `${name}:main.tex`;
    const snapshot = services.latexSource.getSnapshot(wsPath)!;
    const cursor = snapshot.text.indexOf('\n\\end{document}') + 1;

    services.latexSource.applyEdit(wsPath, { from: cursor, to: cursor, text: '..?' }, 'source');

    // Wait until the explicit command has entered Local AI generation, then
    // replay the already-applied system-origin cue-removal notification. Real
    // browser/controller reconciliation can surface this echo after generation
    // starts; it must not abort the requested review.
    for (let attempt = 0; attempt < 20 && platform.generations.length === 0; attempt += 1) {
      await new Promise((resolve) => setTimeout(resolve, 5));
    }
    expect(platform.generations).toHaveLength(1);
    const afterCue = services.latexSource.getSnapshot(wsPath)!;
    const mutationListeners = (services.latexSource as unknown as {
      mutationListeners: Set<(event: { wsPath: string; wsName: string; revision: number; origin: 'system'; edits: readonly { from: number; to: number; text: string }[] }) => void>;
    }).mutationListeners;
    for (const listener of mutationListeners) {
      listener({
        wsPath,
        wsName: name,
        revision: afterCue.revision,
        origin: 'system',
        edits: [{ from: cursor, to: cursor + 3, text: '' }],
      });
    }

    await new Promise((resolve) => setTimeout(resolve, 250));

    expect(services.latexSource.getSnapshot(wsPath)?.text).toBe(source);
    expect(platform.generations).toHaveLength(1);
    expect(services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' })).toHaveLength(1);
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('suggested');
    env.controller.abort();
  });

  it('forces the selected AI Setup provider with `..?` even when Automatic Suggestions are Off', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nWhat is AI \n\\end{document}\n';
    const { env, services, name, platform } = await setup(['What is AI?'], source);
    services.projectAi.configure({
      enabled: true,
      provider: 'local',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: '',
      rememberApiKey: false,
      privacyMode: false,
      autoExplainErrors: false,
    });
    expect(env.store.get(services.projectAiWriting.$settings).automaticSuggestions).toBe('off');
    const wsPath = `${name}:main.tex`;
    const snapshot = services.latexSource.getSnapshot(wsPath)!;
    const cursor = snapshot.text.indexOf('What is AI ') + 'What is AI '.length;

    services.latexSource.applyEdit(wsPath, { from: cursor, to: cursor, text: '..?' }, 'source');
    await new Promise((resolve) => setTimeout(resolve, 220));

    expect(platform.generations).toHaveLength(1);
    expect(services.latexSource.getSnapshot(wsPath)?.text).not.toContain('..?');
    expect(services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' })[0]?.proposedText).toBe('What is AI?');
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('suggested');
    env.controller.abort();
  });

  it('automatic AI follows Local AI in AI Setup without requiring terminal punctuation', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe experimental result demonstrates clear gains\n\\end{document}\n';
    const { env, services, name, platform } = await setup(['The experimental results demonstrate clear gains.'], source);
    services.projectAi.configure({
      enabled: true,
      provider: 'local',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: '',
      rememberApiKey: false,
      privacyMode: false,
      autoExplainErrors: false,
    });
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const wsPath = `${name}:main.tex`;
    const snapshot = services.latexSource.getSnapshot(wsPath)!;
    const insertAt = snapshot.text.indexOf('result') + 'result'.length;

    services.latexSource.applyEdit(wsPath, { from: insertAt, to: insertAt, text: 's' }, 'source');
    await new Promise((resolve) => setTimeout(resolve, 1_150));

    expect(platform.generations).toHaveLength(1);
    const pending = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
    expect(pending).toHaveLength(1);
    expect(pending[0]?.originalText).toBe('The experimental results demonstrates clear gains');
    expect(pending[0]?.proposedText).toBe('The experimental results demonstrate clear gains.');
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('suggested');
    env.controller.abort();
  });

  it('uses inline `..?` as a force command for the current line text before the cue', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nWhat is AI \n\\end{document}\n';
    const response = 'What is AI?';
    const { env, services, name, platform } = await setup([response], source);
    const wsPath = `${name}:main.tex`;
    const snapshot = services.latexSource.getSnapshot(wsPath)!;
    const cursor = snapshot.text.indexOf('What is AI ') + 'What is AI '.length;

    services.latexSource.applyEdit(wsPath, { from: cursor, to: cursor, text: '..?' }, 'source');
    await new Promise((resolve) => setTimeout(resolve, 180));

    expect(services.latexSource.getSnapshot(wsPath)?.text).toContain('What is AI\n');
    expect(services.latexSource.getSnapshot(wsPath)?.text).not.toContain('..?');
    expect(platform.generations).toHaveLength(1);
    expect(platform.generations[0]?.messages.at(-1)?.content).toContain('EXACT_SELECTED_SOURCE:\nWhat is AI');
    const pending = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
    expect(pending).toHaveLength(1);
    expect(pending[0]?.originalText).toBe('What is AI');
    expect(pending[0]?.proposedText).toBe('What is AI?');
    env.controller.abort();
  });

  it('detects an automatic writing opportunity after terminal punctuation and trailing spaces', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThis is really very important for the results.  \n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'This is important for the results.', category: 'writing', reason: 'Tighten wording.', confidence: 0.95 });
    const { env, services, name, platform } = await setup([response], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('.  \n') + 3;

    services.projectAiWriting.observeEditorActivity({
      wsName: name,
      filePath: 'main.tex',
      revision: snapshot.revision,
      mode: 'source',
      cursor,
      selectionFrom: cursor,
      selectionTo: cursor,
      kind: 'text',
    });

    await new Promise((resolve) => setTimeout(resolve, 1_100));
    expect(platform.generations).toHaveLength(1);
    const pending = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
    expect(pending).toHaveLength(1);
    expect(pending[0]?.metadata).toMatchObject({ source: 'ai', automatic: true, category: 'writing' });
    env.controller.abort();
  });

  it('reviews a completed sentence when terminal punctuation is followed by a closing quote', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe authors conclude, "The experimental result demonstrates a clear improvement."\n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'The authors conclude, "The experimental results demonstrate a clear improvement.', category: 'writing', reason: 'Improve agreement while preserving the quoted context.', confidence: 0.92 });
    const { env, services, name, platform } = await setup([response], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('improvement."') + 'improvement."'.length;

    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));

    expect(platform.generations).toHaveLength(1);
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('suggested');
    env.controller.abort();
  });

  it('reviews an ordinary weak academic sentence without requiring magic trigger words', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe experimental results demonstrates that our proposed approach have better accuracy.\n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'The experimental results demonstrate that our proposed approach has better accuracy.', category: 'grammar', reason: 'Fix subject-verb agreement.', confidence: 0.96 });
    const { env, services, name, platform } = await setup([response], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('accuracy.') + 'accuracy.'.length;

    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));

    expect(platform.generations).toHaveLength(1);
    const pending = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
    expect(pending).toHaveLength(1);
    expect(pending[0]?.proposedText).toContain('results demonstrate');
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('suggested');
    env.controller.abort();
  });

  it('reviews the exact clustering sentence from the canonical Source mutation stream without relying on an editor callback', async () => {
    const initial = '\\documentclass{article}\n\\begin{document}\nData clustering  is a distinctive method for analyzing    complex networks in terms of\n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'Data clustering is a distinctive method for analyzing complex networks in terms of network structure.', category: 'clarity', reason: 'Complete and tighten the sentence.', confidence: 0.96 });
    const { env, services, name, platform } = await setup([response], initial);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const wsPath = `${name}:main.tex`;
    const before = services.latexSource.getSnapshot(wsPath)!;
    const insertion = before.text.indexOf('\n\\end{document}');

    // This is the real editor plumbing boundary: LatexSource emits a canonical
    // source mutation. Automatic AI must still schedule even if a UI activity
    // callback is delayed or lost by CodeMirror/Visual reconciliation.
    services.latexSource.applyEdit(wsPath, { from: insertion, to: insertion, text: '.' }, 'source');

    await new Promise((resolve) => setTimeout(resolve, 1_100));
    expect(platform.generations).toHaveLength(1);
    const pending = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
    expect(pending).toHaveLength(1);
    expect(pending[0]?.originalText).toContain('Data clustering');
    expect(pending[0]?.proposedText).toContain('network structure');
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('suggested');
    env.controller.abort();
  });

  it('treats repeated terminal punctuation and ellipsis as the end of the same edited sentence', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nData clustering is a distinctive method for analyzing complex networks in terms of..\n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'Data clustering is a distinctive method for analyzing complex networks.', category: 'clarity', reason: 'Remove incomplete phrasing and repeated punctuation.', confidence: 0.96 });
    const { env, services, name, platform } = await setup([response], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('of..') + 'of..'.length;

    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));

    expect(platform.generations).toHaveLength(1);
    expect(services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' })[0]?.originalText).toContain('Data clustering');
    env.controller.abort();
  });

  it('reviews the reported data-clustering sentence with ordinary spacing noise and terminal punctuation', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nData clustering  is a distinctive method for analyzing    complex networks in terms of.\n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'Data clustering is a distinctive method for analyzing complex networks.', category: 'writing', reason: 'Remove redundant spacing and tighten the sentence.', confidence: 0.96 });
    const { env, services, name, platform } = await setup([response], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('terms of.') + 'terms of.'.length;

    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));

    expect(platform.generations).toHaveLength(1);
    expect(services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' })).toHaveLength(1);
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('suggested');
    env.controller.abort();
  });

  it('keeps an automatic idle review valid across a harmless canonical revision advance', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nData clustering is a distinctive method for analyzing complex networks in terms of.\n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'Data clustering is a distinctive method for analyzing complex networks.', category: 'writing', reason: 'Tighten the sentence.', confidence: 0.95 });
    const { env, services, name, platform } = await setup([response], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('terms of.') + 'terms of.'.length;

    services.projectAiWriting.observeEditorActivity({
      wsName: name,
      filePath: 'main.tex',
      revision: Math.max(0, snapshot.revision - 1),
      mode: 'source',
      cursor,
      kind: 'text',
    });
    await new Promise((resolve) => setTimeout(resolve, 1_100));

    expect(platform.generations).toHaveLength(1);
    expect(services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' })).toHaveLength(1);
    env.controller.abort();
  });

  it('routes automatic suggestions through the configured BYOK AI Setup provider', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe experimental results demonstrates that our proposed approach have better accuracy.\n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'The experimental results demonstrate that our proposed approach has better accuracy.', category: 'grammar', reason: 'Fix subject-verb agreement.', confidence: 0.96 });
    const { env, services, name, platform } = await setup([], source);
    services.projectAi.configure({
      enabled: true,
      provider: 'openai',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: 'gpt-test',
      apiKey: 'browser-memory-test-key',
      rememberApiKey: false,
      privacyMode: false,
      autoExplainErrors: false,
    });
    const configured = vi.spyOn(services.projectAi, 'generateConfigured').mockResolvedValue(response);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('accuracy.') + 'accuracy.'.length;

    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));

    expect(configured).toHaveBeenCalledTimes(1);
    expect(platform.generations).toHaveLength(0);
    const pending = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
    expect(pending).toHaveLength(1);
    expect(pending[0]?.metadata).toMatchObject({ source: 'ai', automatic: true, provider: 'openai', model: 'gpt-test' });
    expect(services.projectAiWriting.getAutomaticStatus()).toMatchObject({ phase: 'suggested', provider: 'openai', model: 'gpt-test' });
    env.controller.abort();
  });

  it('reports a visible no-change state and does not repeatedly review the same unchanged sentence', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe results are consistent with previous studies.\n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'The results are consistent with previous studies.', category: 'writing', reason: 'No change needed.', confidence: 0.42 });
    const { env, services, name, platform } = await setup([response, response], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('studies.') + 'studies.'.length;

    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));
    expect(platform.generations).toHaveLength(1);
    expect(services.projectAiWriting.getAutomaticStatus()).toMatchObject({ phase: 'no-change' });
    expect(services.projectAiWriting.getAutomaticStatus().reason).toMatch(/Checked/u);

    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));
    expect(platform.generations).toHaveLength(1);
    expect(services.projectReview.getChanges(name, { status: 'pending' })).toHaveLength(0);
    env.controller.abort();
  });

  it('shows provider readiness errors before scheduling an unusable BYOK request', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe experimental results demonstrates a consistent improvement.\n\\end{document}\n';
    const { env, services, name } = await setup([], source);
    services.projectAi.configure({
      enabled: true,
      provider: 'openai',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: 'gpt-test',
      apiKey: '',
      rememberApiKey: false,
      privacyMode: false,
      autoExplainErrors: false,
    });
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('improvement.') + 'improvement.'.length;
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor, kind: 'text' });

    expect(services.projectAiWriting.getAutomaticStatus()).toMatchObject({ phase: 'blocked', provider: 'openai' });
    expect(services.projectAiWriting.getAutomaticStatus().reason).toMatch(/API key/u);
    env.controller.abort();
  });

  it('shows Local AI readiness when the selected model is not installed', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe experimental results demonstrates a consistent improvement.\n\\end{document}\n';
    const { env, services, name } = await setup([], source, 0, undefined, false);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    await new Promise((resolve) => setTimeout(resolve, 20));
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('improvement.') + 'improvement.'.length;
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor, kind: 'text' });

    expect(services.projectAiWriting.getAutomaticStatus()).toMatchObject({ phase: 'blocked', provider: 'local' });
    expect(services.projectAiWriting.getAutomaticStatus().reason).toMatch(/Download the selected Local AI model/u);
    env.controller.abort();
  });

  it('surfaces provider failures instead of swallowing automatic watcher errors', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe experimental results demonstrates a consistent improvement.\n\\end{document}\n';
    const { env, services, name } = await setup([], source);
    services.projectAi.configure({
      enabled: true,
      provider: 'openai',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: 'gpt-test',
      apiKey: 'browser-memory-test-key',
      rememberApiKey: false,
      privacyMode: false,
      autoExplainErrors: false,
    });
    vi.spyOn(services.projectAi, 'generateConfigured').mockRejectedValue(new Error('Provider temporarily unavailable'));
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('improvement.') + 'improvement.'.length;
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));

    expect(services.projectAiWriting.getAutomaticStatus()).toMatchObject({ phase: 'error', provider: 'openai' });
    expect(services.projectAiWriting.getAutomaticStatus().reason).toMatch(/temporarily unavailable/u);
    env.controller.abort();
  });

  it('waits longer before offering an automatic continuation for unfinished prose', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe experimental results indicate a consistent improvement across all evaluated datasets and therefore suggest\n\\end{document}\n';
    const response = JSON.stringify({ replacement: ' that the proposed method generalizes across the evaluated conditions.', category: 'writing', reason: 'Complete the sentence conservatively.', confidence: 0.9 });
    const { env, services, name, platform } = await setup([response], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('\n\\end{document}');
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor, kind: 'text' });

    await new Promise((resolve) => setTimeout(resolve, 1_150));
    expect(platform.generations).toHaveLength(0);
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('waiting');
    await new Promise((resolve) => setTimeout(resolve, 800));
    expect(platform.generations).toHaveLength(1);
    env.controller.abort();
  });

  it('blocks automatic AI on an overlapping unresolved Review change until the author resolves it', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\nThe experimental results demonstrates a consistent improvement.\n\\end{document}\n';
    const response = JSON.stringify({ replacement: 'The experimental results demonstrate a consistent improvement.', category: 'grammar', reason: 'Fix agreement.', confidence: 0.95 });
    const { env, services, name, platform } = await setup([response], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const from = source.indexOf('results');
    const to = from + 'results demonstrates'.length;
    await services.projectReview.proposeSourceChange(name, {
      filePath: 'main.tex', from, to, originalText: source.slice(from, to), proposedText: 'results demonstrate', sourceRevision: snapshot.revision,
      author: { id: 'author', displayName: 'Author', kind: 'user' },
    });
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'source', cursor: from + 2, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));

    expect(platform.generations).toHaveLength(0);
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('blocked');
    expect(services.projectAiWriting.getAutomaticStatus().reason).toMatch(/overlapping Review change/u);
    env.controller.abort();
  });

  it('keeps the automatic watcher scheduled through collapsed Visual cursor activity', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\n\\begin{figre}\nCaption text.\n\\end{figre}\n\\end{document}\n';
    const { env, services, name } = await setup([], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('figre') + 2;

    services.projectAiWriting.observeEditorActivity({
      wsName: name,
      filePath: 'main.tex',
      revision: snapshot.revision,
      mode: 'visual',
      cursor,
      selectionFrom: cursor,
      selectionTo: cursor,
      kind: 'text',
    });
    // Visual/ProseMirror commonly emits a collapsed caret notification as part
    // of the same edit. It must update the pending position, not cancel the
    // one-second watcher deadline.
    services.projectAiWriting.observeEditorActivity({
      wsName: name,
      filePath: 'main.tex',
      revision: snapshot.revision,
      mode: 'visual',
      cursor: cursor + 1,
      selectionFrom: cursor + 1,
      selectionTo: cursor + 1,
      kind: 'cursor',
    });

    await new Promise((resolve) => setTimeout(resolve, 1_100));
    const pending = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
    expect(pending).toHaveLength(1);
    expect(pending[0]?.metadata).toMatchObject({ source: 'ai', automatic: true, category: 'latex' });
    env.controller.abort();
  });

  it('still cancels a pending automatic watcher when the author makes a real selection', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\n\\begin{figre}\nCaption text.\n\\end{figre}\n\\end{document}\n';
    const { env, services, name } = await setup([], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('figre') + 2;

    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, mode: 'visual', cursor, kind: 'text' });
    services.projectAiWriting.observeEditorActivity({
      wsName: name,
      filePath: 'main.tex',
      revision: snapshot.revision,
      mode: 'visual',
      cursor: cursor + 4,
      selectionFrom: cursor,
      selectionTo: cursor + 4,
      kind: 'selection',
    });

    await new Promise((resolve) => setTimeout(resolve, 1_100));
    expect(services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' })).toHaveLength(0);
    env.controller.abort();
  });

  it('keeps the automatic watcher off the keystroke hot path and resolves source only after idle', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\n\\begin{figre}\nCaption text.\n\\end{figre}\n\\end{document}\n';
    const { env, services, name } = await setup([], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('figre') + 2;
    const getChangesSpy = vi.spyOn(services.projectReview, 'getChanges');

    // Editor-originated activity intentionally omits the full source snapshot.
    services.projectAiWriting.observeEditorActivity({
      wsName: name,
      filePath: 'main.tex',
      revision: snapshot.revision,
      mode: 'source',
      cursor,
      kind: 'text',
    });

    await new Promise((resolve) => setTimeout(resolve, 50));
    expect(getChangesSpy).not.toHaveBeenCalled();

    await new Promise((resolve) => setTimeout(resolve, 1_100));
    expect(getChangesSpy).toHaveBeenCalled();
    expect(services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' })).toHaveLength(1);
    env.controller.abort();
  });



  it('suppresses automatic AI while native autocomplete or the Visual slash menu is open', async () => {
    const response = JSON.stringify({ replacement: 'This is important for the results.', category: 'writing', reason: 'Tighten wording.', confidence: 0.95 });
    const { env, services, name, source, platform } = await setup([response]);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('results.') + 'results'.length;
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source, mode: 'source', cursor, kind: 'text' });
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source, mode: 'source', cursor, kind: 'autocomplete-open' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));
    expect(platform.generations).toHaveLength(0);
    expect(services.projectReview.getChanges(name, { status: 'pending' })).toHaveLength(0);

    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source, mode: 'source', cursor, kind: 'autocomplete-close' });
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));
    expect(platform.generations).toHaveLength(1);
    env.controller.abort();
  });

  it('does not abort an active automatic model request for harmless collapsed cursor activity', async () => {
    const response = JSON.stringify({ replacement: 'This is important for the results.', category: 'writing', reason: 'Tighten wording.', confidence: 0.95 });
    const { env, services, name, source, platform } = await setup([response], undefined, 450);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('results.') + 'results.'.length;
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_060));
    expect(platform.generations).toHaveLength(1);
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('generating');
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source, mode: 'source', cursor, selectionFrom: cursor, selectionTo: cursor, kind: 'cursor' });
    await new Promise((resolve) => setTimeout(resolve, 500));
    expect(services.projectReview.getChanges(name, { status: 'pending' })).toHaveLength(1);
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('suggested');
    env.controller.abort();
  });

  it('aborts a stale automatic model request when real text editing resumes', async () => {
    const response = JSON.stringify({ replacement: 'This is important for the results.', category: 'writing', reason: 'Tighten wording.', confidence: 0.95 });
    const { env, services, name, source, platform } = await setup([response, response], undefined, 450);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    let snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const cursor = source.indexOf('results.') + 'results.'.length;
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_060));
    expect(platform.generations).toHaveLength(1);

    const edited = source.replace('results.', 'results today.');
    services.latexSource.update(`${name}:main.tex`, edited, 'source');
    await services.latexSource.flush(`${name}:main.tex`);
    snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const editedCursor = edited.indexOf('results today.') + 'results today.'.length;
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source: edited, mode: 'source', cursor: editedCursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 500));
    expect(services.projectReview.getChanges(name, { status: 'pending' })).toHaveLength(0);
    expect(services.projectAiWriting.getAutomaticStatus().phase).toBe('waiting');
    env.controller.abort();
  });

  it('suppresses automatic suggestions while a manual AI request is running', async () => {
    const response = JSON.stringify({ replacement: 'This is important for the results.', category: 'writing', reason: 'Tighten wording.', confidence: 0.95 });
    const { env, services, name, source, platform } = await setup([response], undefined, 350);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    const snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    const from = source.indexOf('This is');
    const to = source.indexOf('\n', from);
    const manual = services.projectAiWriting.suggestSelection(name, { filePath: 'main.tex', from, to, action: 'improve-writing' });
    await new Promise((resolve) => setTimeout(resolve, 25));
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source, mode: 'source', cursor: to, kind: 'text' });
    await manual;
    await new Promise((resolve) => setTimeout(resolve, 1_100));
    expect(platform.generations).toHaveLength(1);
    expect(services.projectReview.getChanges(name, { status: 'pending' })).toHaveLength(1);
    env.controller.abort();
  });

  it('keeps a rejected automatic suggestion on cooldown only while that source range is unchanged', async () => {
    const source = '\\documentclass{article}\n\\begin{document}\n\\begin{figre}\nCaption text.\n\\end{figre}\n\\end{document}\n';
    const { env, services, name } = await setup([], source);
    services.projectAiWriting.configureAutomaticSuggestions('provider');
    let snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    let cursor = source.indexOf('figre') + 2;
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));
    const first = services.projectReview.getChanges(name, { status: 'pending' })[0]!;
    await services.projectReview.rejectReviewChange(name, first.id);

    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));
    expect(services.projectReview.getChanges(name, { status: 'pending' })).toHaveLength(0);

    const edited = source.replace('Caption text.', 'Updated caption text.');
    services.latexSource.update(`${name}:main.tex`, edited, 'source');
    await services.latexSource.flush(`${name}:main.tex`);
    snapshot = services.latexSource.getSnapshot(`${name}:main.tex`)!;
    cursor = edited.indexOf('figre') + 2;
    services.projectAiWriting.observeEditorActivity({ wsName: name, filePath: 'main.tex', revision: snapshot.revision, source: edited, mode: 'source', cursor, kind: 'text' });
    await new Promise((resolve) => setTimeout(resolve, 1_100));
    expect(services.projectReview.getChanges(name, { status: 'pending' })).toHaveLength(1);
    env.controller.abort();
  });



  it('rehydrates an orphaned running browser checkpoint as paused and resumable instead of a ghost running job', async () => {
    const { env, services, name } = await setup([JSON.stringify({ findings: [] })]);
    const completed = await services.projectAiWriting.startPaperReview(name, { mode: 'quick', engineMode: 'local', includePaperMemory: false, includeLiterature: false });
    await overwriteReviewCheckpoint(completed.projectId, { ...completed, status: 'running', currentStage: 'Writing/positioning reviewer', error: undefined });
    const internals = services.projectAiWriting as unknown as { latestRuns: Map<string, unknown> };
    internals.latestRuns.clear();

    const restored = await services.projectAiWriting.loadLatestReviewRun(name);
    expect(restored?.status).toBe('paused');
    expect(restored?.currentStage).toBe('Writing/positioning reviewer');
    expect(restored?.error).toMatch(/previous browser session ended/i);
    env.controller.abort();
  });

  it('cancels a running review and marks its checkpoint outdated when the manuscript changes before resume', async () => {
    const reviewer = JSON.stringify({ findings: [] });
    const { env, services, name, source, platform } = await setup([reviewer], undefined, 500);
    const running = services.projectAiWriting.startPaperReview(name, { mode: 'quick', engineMode: 'local', includePaperMemory: false, includeLiterature: false });
    for (let attempt = 0; attempt < 40 && platform.generations.length === 0; attempt += 1) {
      await new Promise((resolve) => setTimeout(resolve, 10));
    }
    expect(platform.generations).toHaveLength(1);
    services.projectAiWriting.cancelPaperReview(name);
    const cancelled = await running;
    expect(cancelled.status).toBe('cancelled');

    const edited = source.replace('very important', 'important');
    services.latexSource.update(`${name}:main.tex`, edited, 'source');
    await services.latexSource.flush(`${name}:main.tex`);
    const resumed = await services.projectAiWriting.resumePaperReview(name);
    expect(resumed.status).toBe('outdated');
    expect(resumed.sourceRevision).toBe(cancelled.sourceRevision);
    expect(resumed.error).toMatch(/changed since this review checkpoint/i);
    env.controller.abort();
  });


  it('adds deterministic source-grounded findings for missing citations, uncaptained figures and theorem proof coverage', async () => {
    const source = String.raw`\documentclass{article}
\newtheorem{theorem}{Theorem}
\begin{document}
Prior work is discussed here~\cite{missing2026}.
\begin{theorem}
Every test value is positive.
\end{theorem}
\begin{figure}
\centering
\rule{1cm}{1cm}
\label{fig:unreferenced}
\end{figure}
\end{document}
`;
    const { env, services, name } = await setup([JSON.stringify({ findings: [] })], source);
    const run = await services.projectAiWriting.startPaperReview(name, { mode: 'quick', engineMode: 'local', includePaperMemory: false, includeLiterature: false });

    expect(run.findings.some((finding) => finding.category === 'citations' && finding.title.includes('missing2026') && finding.verification === 'verified')).toBe(true);
    expect(run.findings.some((finding) => finding.category === 'figures-tables' && /no detected caption/i.test(finding.title))).toBe(true);
    expect(run.findings.some((finding) => finding.category === 'figures-tables' && /fig:unreferenced/u.test(finding.title) && /never referenced/i.test(finding.title))).toBe(true);
    expect(run.findings.some((finding) => finding.category === 'theory' && /proof block/i.test(finding.title))).toBe(true);
    expect(run.findings.filter((finding) => finding.location).every((finding) => (finding.location?.from ?? -1) >= 0 && (finding.location?.to ?? 0) >= (finding.location?.from ?? 0))).toBe(true);
    env.controller.abort();
  });

  it('does not flag a labeled figure as unreferenced when the project contains a real cross-reference', async () => {
    const source = String.raw`\documentclass{article}
\begin{document}
See Figure~\ref{fig:used}.
\begin{figure}
\centering
\rule{1cm}{1cm}
\caption{A referenced fixture figure.}
\label{fig:used}
\end{figure}
\end{document}
`;
    const { env, services, name } = await setup([JSON.stringify({ findings: [] })], source);
    const run = await services.projectAiWriting.startPaperReview(name, { mode: 'quick', engineMode: 'local', includePaperMemory: false, includeLiterature: false });

    expect(run.findings.some((finding) => /fig:used/u.test(finding.title) && /never referenced/i.test(finding.title))).toBe(false);
    env.controller.abort();
  });

  it('detects duplicate bibliography works deterministically without merging or inventing metadata', async () => {
    const source = String.raw`\documentclass{article}
\begin{document}
A bibliography regression fixture.
\bibliography{references}
\end{document}
`;
    const { env, services, name } = await setup([JSON.stringify({ findings: [] })], source);
    const bib = '@article{first2026,\n  title={A Shared Research Work},\n  author={Author, A.},\n  year={2026},\n  doi={10.1000/example}\n}\n\n@article{second2026,\n  title={A Shared Research Work},\n  author={Author, A.},\n  year={2026},\n  doi={https://doi.org/10.1000/example}\n}\n';
    await services.fileSystem.createTextFile(`${name}:references.bib`, bib);
    await services.latexSource.load(`${name}:references.bib`);
    const run = await services.projectAiWriting.startPaperReview(name, { mode: 'quick', engineMode: 'local', includePaperMemory: false, includeLiterature: false });

    const duplicate = run.findings.find((finding) => /Possible duplicate bibliography entries/u.test(finding.title));
    expect(duplicate?.category).toBe('citations');
    expect(duplicate?.verification).toBe('verified');
    expect(duplicate?.location?.filePath).toBe('references.bib');
    expect(duplicate?.detail).toContain('10.1000/example');
    env.controller.abort();
  });




  it('builds one generic multi-file review context across custom class/style, chapters, equations, theorem/proof, tables, bibliography and appendices', async () => {
    const source = String.raw`\documentclass{researchfixture}
\usepackage{researchmacros}
\usepackage{amsmath,amsthm}
\newtheorem{theorem}{Theorem}
\title{Generic Multi-file Review Fixture}
\begin{document}
\input{chapters/method}
\input{chapters/results}
\bibliography{references}
\appendix
\input{chapters/appendix}
\end{document}
`;
    const compiler = new ReviewTestCompiler();
    const { env, services, name, platform } = await setup([
      JSON.stringify({ findings: [] }),
      JSON.stringify({ findings: [] }),
      JSON.stringify({ findings: [] }),
    ], source, 0, compiler);
    await services.fileSystem.createTextFile(`${name}:researchfixture.cls`, String.raw`\NeedsTeXFormat{LaTeX2e}
\ProvidesClass{researchfixture}
\LoadClass{article}
`);
    await services.fileSystem.createTextFile(`${name}:researchmacros.sty`, String.raw`\ProvidesPackage{researchmacros}
\newcommand{\methodname}{FixtureNet}
`);
    await services.fileSystem.createTextFile(`${name}:chapters/method.tex`, String.raw`\section{Method}
\begin{theorem}\label{thm:fixture}
For every input $x$, the fixture score is non-negative.
\end{theorem}
\begin{proof}
The score is a squared norm.
\end{proof}
\begin{equation}\label{eq:fixture}
s(x)=\lVert x\rVert_2^2.
\end{equation}
`);
    await services.fileSystem.createTextFile(`${name}:chapters/results.tex`, String.raw`\section{Experiments}
We evaluate \methodname{} on three benchmark datasets against two baselines using accuracy.
\begin{table}
\caption{Benchmark accuracy for the generic fixture.}
\label{tab:fixture}
\begin{tabular}{lr}
Method & Accuracy \\
Baseline & 81.0 \\
\methodname & 84.2 \\
\end{tabular}
\end{table}
Table~\ref{tab:fixture} summarizes the result.
`);
    await services.fileSystem.createTextFile(`${name}:chapters/appendix.tex`, String.raw`\section{Additional Details}
The learning rate is 0.001 and the random seed is 7.
`);
    await services.fileSystem.createTextFile(`${name}:references.bib`, '@article{fixture2026,\n  title={A Generic Fixture Work},\n  author={Author, A.},\n  year={2026}\n}\n');

    const run = await services.projectAiWriting.startPaperReview(name, { mode: 'paper', engineMode: 'local', includePaperMemory: false, includeLiterature: false });
    expect(run.status).toBe('complete');
    expect(platform.generations).toHaveLength(3);
    const prompts = platform.generations.map((request) => request.messages.map((message) => message.content).join('\n'));
    for (const filePath of ['main.tex', 'researchfixture.cls', 'researchmacros.sty', 'chapters/method.tex', 'chapters/results.tex', 'chapters/appendix.tex', 'references.bib']) {
      expect(prompts.some((prompt) => prompt.includes(filePath))).toBe(true);
    }
    expect(prompts.some((prompt) => prompt.includes('\\methodname') && prompt.includes('FixtureNet'))).toBe(true);
    expect(prompts.some((prompt) => prompt.includes('eq:fixture') || prompt.includes('s(x)'))).toBe(true);
    expect(prompts.some((prompt) => prompt.includes('Benchmark accuracy for the generic fixture'))).toBe(true);
    expect(prompts.some((prompt) => prompt.includes('Additional Details'))).toBe(true);
    env.controller.abort();
  });

  it('collects bounded real raster figure evidence for Paper Review without placing image bytes in Local AI prompts or checkpoints', async () => {
    const source = String.raw`\documentclass{article}
\usepackage{graphicx}
\begin{document}
\section{Results}
Figure~\ref{fig:result} summarizes the experiment.
\begin{figure}
\centering
\includegraphics{result-plot}
\caption{Measured benchmark accuracy.}
\label{fig:result}
\end{figure}
\end{document}
`;
    const compiler = new ReviewTestCompiler();
    const { env, services, name, platform } = await setup([
      JSON.stringify({ findings: [] }),
      JSON.stringify({ findings: [] }),
      JSON.stringify({ findings: [] }),
    ], source, 0, compiler);
    await services.fileSystem.createTextFile(`${name}:result-plot.png`, '\u0089PNG-fixture-bytes');
    const run = await services.projectAiWriting.startPaperReview(name, { mode: 'paper', engineMode: 'local', includePaperMemory: false, includeLiterature: false });

    expect(run.artifacts.visualFigureEvidenceCount).toBe(1);
    expect(run.completedStages).toContain('Preparing figure evidence');
    expect(platform.generations).toHaveLength(3);
    const prompts = platform.generations.flatMap((request) => request.messages.map((message) => message.content));
    expect(prompts.some((prompt) => prompt.includes('result-plot.png') && prompt.includes('visualFigureAssets'))).toBe(true);
    expect(prompts.some((prompt) => prompt.includes('iVBOR'))).toBe(false);
    const provider = await services.projectStorage.getProvider(name);
    const checkpointPath = run.artifacts.reportPath!;
    const report = new TextDecoder().decode(await provider.readFile(projectFilePath(checkpointPath)));
    expect(report).toContain('Raster figure assets supplied to compatible cloud reviewers: 1');
    expect(report).not.toContain('PNG-fixture-bytes');
    env.controller.abort();
  });

  it('keeps Paper Review literature grounded in real scholarly-search records and ignores malformed metadata', async () => {
    const source = String.raw`\documentclass{article}
\title{Robust Graph Learning}
\begin{document}
\begin{abstract}We propose a robust graph-learning method and demonstrate improvements on benchmark datasets.\end{abstract}
\section{Introduction}
We propose a robust method that improves benchmark performance across datasets.
\end{document}
`;
    const compiler = new ReviewTestCompiler();
    const originalFetch = globalThis.fetch;
    const requests: unknown[] = [];
    globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
      requests.push(init?.body ? JSON.parse(String(init.body)) : undefined);
      return new Response(JSON.stringify({ records: [
        { id: 'openalex:W123', title: 'Robust Graph Learning in Practice', authors: ['A. Researcher'], year: 2025, doi: '10.1234/real.2025.1', abstractText: 'A real retrieved abstract about robust graph learning.', url: 'https://doi.org/10.1234/real.2025.1', sourceProviders: ['OpenAlex'] },
        { id: '', title: 'Malformed record without identifier' },
        { id: 'missing-title' },
      ] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
    }) as typeof fetch;
    try {
      const { env, services, name } = await setup([
        JSON.stringify({ findings: [] }),
        JSON.stringify({ findings: [] }),
        JSON.stringify({ findings: [] }),
      ], source, 0, compiler);
      const run = await services.projectAiWriting.startPaperReview(name, { mode: 'paper', engineMode: 'local', includePaperMemory: false, includeLiterature: true });

      expect(requests.length).toBeGreaterThan(0);
      expect(run.literature).toHaveLength(1);
      expect(run.literature[0]).toMatchObject({ id: 'openalex:W123', doi: '10.1234/real.2025.1', sourceProviders: ['OpenAlex'] });
      expect(run.literature.some((record) => record.id === 'missing-title')).toBe(false);
      env.controller.abort();
    } finally {
      globalThis.fetch = originalFetch;
    }
  });

  it('detects reviewer disagreement, gives the Judge grounded arguments, and replaces the conflicting cluster with one resolved finding', async () => {
    const source = String.raw`\documentclass{article}
\begin{document}
\section{Evaluation}
Scalability is evaluated on a graph with 120000 nodes, and the manuscript claims broad scalability.
\end{document}
`;
    const from = source.indexOf('Scalability');
    const to = source.indexOf('\n', from);
    const reviewer = (severity: 'minor' | 'moderate' | 'major', detail: string) => JSON.stringify({ findings: [{
      topic: 'scalability evidence',
      title: 'Scalability claim needs calibration',
      detail,
      severity,
      category: 'experiments',
      filePath: 'main.tex',
      from,
      to,
      quote: source.slice(from, to),
      evidenceIds: [],
      confidence: severity === 'minor' ? 0.8 : severity === 'major' ? 0.92 : 0.86,
      actionable: true,
    }] });
    const judge = JSON.stringify({
      topic: 'scalability evidence',
      title: 'Scalability evidence is limited',
      detail: 'The manuscript evaluates 120000 nodes, so the broad scalability claim should be narrowed.',
      severity: 'moderate',
      confidence: 0.94,
      evidenceIds: ['invented-evidence-id'],
    });
    const meta = JSON.stringify({
      summary: 'The paper has a moderate scalability-evidence concern.',
      strengths: ['Clear evaluation statement.'],
      majorIssues: [],
      minorIssues: ['Scalability evidence is limited.'],
      recommendedRevisions: ['Narrow the scalability claim.'],
      scores: { novelty: 7, technicalSoundness: 7, experimentalQuality: 6, clarity: 8, reproducibility: 7, relatedWork: 7, overall: 7 },
    });
    const compiler = new ReviewTestCompiler();
    const { env, services, name, platform } = await setup([
      reviewer('minor', 'No major scalability concern.'),
      reviewer('major', 'The largest evaluation is too small for a broad scalability claim.'),
      reviewer('moderate', 'The claim should be narrowed to the demonstrated scale.'),
      judge,
      meta,
    ], source, 0, compiler);

    const run = await services.projectAiWriting.startPaperReview(name, { mode: 'deep', engineMode: 'local', includePaperMemory: false, includeLiterature: false });
    const resolved = run.findings.filter((finding) => finding.topic === 'scalability evidence');
    expect(compiler.requests).toHaveLength(1);
    expect(platform.generations).toHaveLength(5);
    expect(resolved).toHaveLength(1);
    expect(resolved[0]).toMatchObject({ reviewer: 'judge', severity: 'moderate', verification: 'verified' });
    expect(resolved[0]?.disagreement).toContain('technical: minor');
    expect(resolved[0]?.disagreement).toContain('experimental: major');
    expect(resolved[0]?.evidenceIds).toEqual([]);
    expect(run.metaReview?.scores.overall).toBe(7);
    env.controller.abort();
  });

  it('writes a portable Quick Review Markdown artifact without storing credentials', async () => {
    const reviewer = JSON.stringify({ findings: [{ topic: 'wording', title: 'Reduce informal emphasis', detail: 'The sentence uses multiple intensifiers.', severity: 'minor', category: 'writing', filePath: 'main.tex', from: 41, to: 87, quote: 'This is really very important for the results.', evidenceIds: [], confidence: 0.9, actionable: true }] });
    const { env, services, name } = await setup([reviewer]);
    const run = await services.projectAiWriting.startPaperReview(name, { mode: 'quick', engineMode: 'local', includePaperMemory: false, includeLiterature: false });

    expect(run.status).toBe('complete');
    expect(run.artifacts.reportPath).toMatch(/^\.learnitive\/reviews\/review-.*\.md$/u);
    const provider = await services.projectStorage.getProvider(name);
    const bytes = await provider.readFile(projectFilePath(run.artifacts.reportPath!));
    const markdown = new TextDecoder().decode(bytes);
    expect(markdown).toContain('# Learnitive Paper Review');
    expect(markdown).toContain(`Project revision: \`${run.sourceRevision}\``);
    expect(markdown).toContain('AI reviewer assessment');
    expect(markdown).not.toMatch(/api[_ -]?key|authorization:/iu);
    env.controller.abort();
  });
});
