import type { SourceMutationOrigin } from '@learnitive/latex-source';
import type { LatexSourceDocumentSnapshot } from '@learnitive/service-core';
import { describe, expect, it } from 'vitest';
import { LatexDocumentController } from '../latex-document-controller';

type PendingLoad = {
  resolve: (value: string) => void;
  reject: (reason: unknown) => void;
};

function snapshot(
  wsPath: string,
  text: string,
  revision = 0,
  lastOrigin?: SourceMutationOrigin,
): LatexSourceDocumentSnapshot {
  return {
    wsPath,
    text,
    revision,
    ...(lastOrigin === undefined ? {} : { lastOrigin }),
    dirty: false,
    saving: false,
    externalConflict: false,
    canUndo: false,
    canRedo: false,
  };
}

function createDelayedSource(files: Record<string, string>) {
  const loaded = new Map<string, LatexSourceDocumentSnapshot>();
  const pending = new Map<string, PendingLoad[]>();
  const listeners = new Map<string, Set<() => void>>();

  return {
    source: {
      load(wsPath: string) {
        const already = loaded.get(wsPath);
        if (already) return Promise.resolve(already.text);
        return new Promise<string>((resolve, reject) => {
          const queue = pending.get(wsPath) ?? [];
          queue.push({
            resolve: (text) => {
              loaded.set(wsPath, snapshot(wsPath, text));
              resolve(text);
            },
            reject,
          });
          pending.set(wsPath, queue);
        });
      },
      getSnapshot(wsPath: string) {
        return loaded.get(wsPath);
      },
      subscribe(wsPath: string, listener: () => void) {
        const set = listeners.get(wsPath) ?? new Set();
        set.add(listener);
        listeners.set(wsPath, set);
        return () => set.delete(listener);
      },
      applyEdit(
        wsPath: string,
        edit: { from: number; to: number; text: string },
      ) {
        const current = loaded.get(wsPath);
        if (!current) throw new Error(`Source not loaded: ${wsPath}`);
        const text = current.text.slice(0, edit.from) + edit.text + current.text.slice(edit.to);
        loaded.set(wsPath, snapshot(wsPath, text, current.revision + 1));
        for (const listener of listeners.get(wsPath) ?? []) listener();
      },
      applyEdits(
        wsPath: string,
        edits: readonly { from: number; to: number; text: string }[],
      ) {
        const current = loaded.get(wsPath);
        if (!current) throw new Error(`Source not loaded: ${wsPath}`);
        let text = current.text;
        for (const edit of [...edits].sort((a, b) => b.from - a.from || b.to - a.to)) {
          text = text.slice(0, edit.from) + edit.text + text.slice(edit.to);
        }
        loaded.set(wsPath, snapshot(wsPath, text, current.revision + 1));
        for (const listener of listeners.get(wsPath) ?? []) listener();
      },
    },
    resolve(wsPath: string) {
      const next = pending.get(wsPath)?.shift();
      if (!next) throw new Error(`No pending load for ${wsPath}`);
      next.resolve(files[wsPath] ?? '');
    },
    update(
      wsPath: string,
      text: string,
      revision: number,
      lastOrigin?: SourceMutationOrigin,
    ) {
      loaded.set(wsPath, snapshot(wsPath, text, revision, lastOrigin));
      for (const listener of listeners.get(wsPath) ?? []) listener();
    },
  };
}

describe('R14.17 LatexDocumentController', () => {
  it('allows only the newest route request to become active when loads resolve out of order', async () => {
    const delayed = createDelayedSource({
      'project:main.tex': 'MAIN-CONTENT',
      'project:chapter.tex': 'CHAPTER-CONTENT',
      'project:appendix.tex': 'APPENDIX-CONTENT',
    });
    const controller = new LatexDocumentController(delayed.source);

    const main = controller.requestPath('project:main.tex');
    const chapter = controller.requestPath('project:chapter.tex');
    const appendix = controller.requestPath('project:appendix.tex');

    delayed.resolve('project:chapter.tex');
    delayed.resolve('project:main.tex');
    expect(controller.getSnapshot()).toMatchObject({
      requestedPath: 'project:appendix.tex',
      loadingPath: 'project:appendix.tex',
      status: 'loading',
    });

    delayed.resolve('project:appendix.tex');
    await Promise.all([main, chapter, appendix]);

    expect(controller.getSnapshot()).toMatchObject({
      requestedPath: 'project:appendix.tex',
      activePath: 'project:appendix.tex',
      content: 'APPENDIX-CONTENT',
      status: 'ready',
    });
    controller.dispose();
  });

  it('does not expose previous-file content under a newly requested filename while loading', async () => {
    const delayed = createDelayedSource({
      'project:main.tex': 'MAIN-CONTENT',
      'project:chapter.tex': 'CHAPTER-CONTENT',
    });
    const controller = new LatexDocumentController(delayed.source);

    const main = controller.requestPath('project:main.tex');
    delayed.resolve('project:main.tex');
    await main;
    expect(controller.getSnapshot().content).toBe('MAIN-CONTENT');

    const chapter = controller.requestPath('project:chapter.tex');
    expect(controller.getSnapshot()).toMatchObject({
      requestedPath: 'project:chapter.tex',
      loadingPath: 'project:chapter.tex',
      content: '',
      status: 'loading',
    });
    expect(controller.getSnapshot().activePath).toBeUndefined();

    delayed.resolve('project:chapter.tex');
    await chapter;
    expect(controller.getSnapshot().content).toBe('CHAPTER-CONTENT');
    controller.dispose();
  });

  it('keeps the controller synchronized with canonical revisions only for the active path', async () => {
    const delayed = createDelayedSource({
      'project:main.tex': 'MAIN-CONTENT',
      'project:chapter.tex': 'CHAPTER-CONTENT',
    });
    const controller = new LatexDocumentController(delayed.source);

    const main = controller.requestPath('project:main.tex');
    delayed.resolve('project:main.tex');
    await main;
    delayed.update('project:main.tex', 'MAIN-EDITED', 7);
    expect(controller.getSnapshot()).toMatchObject({ content: 'MAIN-EDITED', revision: 7 });

    const chapter = controller.requestPath('project:chapter.tex');
    delayed.resolve('project:chapter.tex');
    await chapter;
    delayed.update('project:main.tex', 'MAIN-BACKGROUND', 8);
    expect(controller.getSnapshot()).toMatchObject({
      activePath: 'project:chapter.tex',
      content: 'CHAPTER-CONTENT',
      revision: 0,
    });
    controller.dispose();
  });

  it('publishes the canonical mutation origin with each active source revision', async () => {
    const delayed = createDelayedSource({
      'project:main.tex': 'Before',
    });
    const controller = new LatexDocumentController(delayed.source);

    const load = controller.requestPath('project:main.tex');
    delayed.resolve('project:main.tex');
    await load;

    delayed.update('project:main.tex', 'Source edit', 1, 'source');
    expect(controller.getSnapshot()).toMatchObject({
      content: 'Source edit',
      revision: 1,
      lastOrigin: 'source',
    });

    delayed.update('project:main.tex', 'Visual edit', 2, 'visual');
    expect(controller.getSnapshot()).toMatchObject({
      content: 'Visual edit',
      revision: 2,
      lastOrigin: 'visual',
    });
    controller.dispose();
  });

  it('can explicitly republish the active canonical snapshot after an external Visual mutation', async () => {
    const delayed = createDelayedSource({ 'project:main.tex': 'Before' });
    const controller = new LatexDocumentController(delayed.source);
    const load = controller.requestPath('project:main.tex');
    delayed.resolve('project:main.tex');
    await load;

    // Simulate a source owner whose mutation has landed but whose normal
    // notification is not relied on by the caller's current interaction.
    delayed.update('project:main.tex', 'After TikZ', 3, 'visual-structure');
    const refreshed = controller.refreshActiveSource('project:main.tex');

    expect(refreshed).toMatchObject({ text: 'After TikZ', revision: 3 });
    expect(controller.getSnapshot()).toMatchObject({
      content: 'After TikZ',
      revision: 3,
      lastOrigin: 'visual-structure',
    });
    expect(controller.refreshActiveSource('project:other.tex')).toBeUndefined();
    controller.dispose();
  });

  it('restores per-file history only when the canonical revision still matches', () => {
    const delayed = createDelayedSource({});
    const controller = new LatexDocumentController(delayed.source);
    const saved = {
      selection: { anchor: 120, head: 125 },
      scrollTop: 4200,
      scrollLeft: 17,
      history: { done: ['source-edit'] },
    };

    controller.saveSourceViewState('project:main.tex', {
      revision: 12,
      codeMirror: saved,
    });

    expect(controller.getSourceViewState('project:main.tex', 12)).toEqual(saved);
    expect(controller.getSourceViewState('project:main.tex', 13)).toEqual({
      selection: saved.selection,
      scrollTop: 4200,
      scrollLeft: 17,
    });
    controller.dispose();
  });

  it('survives 100 main/chapter/style/bib switches with exact content and no refresh state', async () => {
    const files = {
      'project:main.tex': 'MAIN-CONTENT',
      'project:chapter.tex': 'CHAPTER-CONTENT',
      'project:style.sty': 'STYLE-CONTENT',
      'project:refs.bib': 'BIB-CONTENT',
    };
    const delayed = createDelayedSource(files);
    const controller = new LatexDocumentController(delayed.source);
    const paths = Object.keys(files);
    const firstLoads = new Set<string>();

    for (let index = 0; index < 100; index += 1) {
      const wsPath = paths[index % paths.length]!;
      const request = controller.requestPath(wsPath);
      if (!firstLoads.has(wsPath)) {
        firstLoads.add(wsPath);
        delayed.resolve(wsPath);
      }
      await request;
      expect(controller.getSnapshot()).toMatchObject({
        requestedPath: wsPath,
        activePath: wsPath,
        content: files[wsPath as keyof typeof files],
        status: 'ready',
      });
    }
    controller.dispose();
  });
});


describe('R14.18 canonical exact-edit contract', () => {
  it('applies a Visual/MathLive splice only against the active expected revision', async () => {
    const delayed = createDelayedSource({ 'project:main.tex': 'Before $x$ after' });
    const controller = new LatexDocumentController(delayed.source);
    const load = controller.requestPath('project:main.tex');
    delayed.resolve('project:main.tex');
    await load;

    const current = controller.getSnapshot();
    const from = current.content.indexOf('$x$');
    const result = controller.applyExactEdit('project:main.tex', {
      expectedRevision: current.revision,
      from,
      to: from + 3,
      insert: '$y$',
      origin: 'mathlive',
    });
    expect(result.applied).toBe(true);
    expect(controller.getSnapshot()).toMatchObject({
      content: 'Before $y$ after',
      revision: current.revision + 1,
    });

    const stale = controller.applyExactEdit('project:main.tex', {
      expectedRevision: current.revision,
      from,
      to: from + 3,
      insert: '$z$',
      origin: 'visual',
    });
    expect(stale).toMatchObject({ applied: false, reason: 'revision-mismatch' });
    expect(controller.getSnapshot().content).toBe('Before $y$ after');
    controller.dispose();
  });

  it('applies a TikZ-style preamble plus body insertion atomically against one revision', async () => {
    const initial = String.raw`\documentclass{article}
\begin{document}
\end{document}`;
    const delayed = createDelayedSource({ 'project:main.tex': initial });
    const controller = new LatexDocumentController(delayed.source);
    const load = controller.requestPath('project:main.tex');
    delayed.resolve('project:main.tex');
    await load;

    const current = controller.getSnapshot();
    const preambleAt = current.content.indexOf(String.raw`\begin{document}`);
    const bodyAt = current.content.indexOf(String.raw`\end{document}`);
    const result = controller.applyExactEdits('project:main.tex', {
      expectedRevision: current.revision,
      edits: [
        { from: bodyAt, to: bodyAt, insert: String.raw`\begin{tikzpicture}
\draw (0,0) -- (1,1);
\end{tikzpicture}
` },
        { from: preambleAt, to: preambleAt, insert: String.raw`\usepackage{tikz}
` },
      ],
      origin: 'visual-structure',
    });

    expect(result.applied).toBe(true);
    expect(controller.getSnapshot().content).toContain(String.raw`\usepackage{tikz}`);
    expect(controller.getSnapshot().content).toContain(String.raw`\begin{tikzpicture}`);
    expect(controller.getSnapshot().revision).toBe(current.revision + 1);
    controller.dispose();
  });

  it('rejects exact edits for a document that is not the active route session', async () => {
    const delayed = createDelayedSource({
      'project:main.tex': 'MAIN',
      'project:chapter.tex': 'CHAPTER',
    });
    const controller = new LatexDocumentController(delayed.source);
    const main = controller.requestPath('project:main.tex');
    delayed.resolve('project:main.tex');
    await main;
    const chapter = controller.requestPath('project:chapter.tex');
    delayed.resolve('project:chapter.tex');
    await chapter;

    const result = controller.applyExactEdit('project:main.tex', {
      expectedRevision: 0,
      from: 0,
      to: 4,
      insert: 'NOPE',
      origin: 'visual',
    });
    expect(result).toMatchObject({ applied: false, reason: 'inactive-document' });
    controller.dispose();
  });
});
