import { WORKSPACE_STORAGE_TYPE } from '@learnitive/constants';
import type {
  LatexCompileRequest,
  LatexCompileResult,
  LatexCompiler,
} from '@learnitive/latex-compiler';
import { projectFilePath } from '@learnitive/project-storage';
import { createTestEnvironment } from '@learnitive/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';

const sp = (points: number) => Math.round(points * 65536);
const synctex = new TextEncoder().encode(
  [
    'SyncTeX Version:1',
    'Input:1:/work/main.tex',
    'Input:2:/work/chapters/results.tex',
    'Content:',
    '{1',
    `h2,42:${sp(30)},${sp(60)}:${sp(200)},${sp(12)},${sp(3)}`,
    '}',
    '{2',
    `h2,80:${sp(25)},${sp(50)}:${sp(180)},${sp(11)},${sp(2)}`,
    '}',
    'Postamble:',
  ].join('\n'),
);

class SequencedCompiler implements LatexCompiler {
  readonly backend = 'busytex' as const;
  shouldFail = false;
  omitSyncTex = false;
  supports(): boolean {
    return true;
  }
  async initialize(): Promise<void> {}
  async compile(request: LatexCompileRequest): Promise<LatexCompileResult> {
    if (this.shouldFail) {
      return {
        success: false,
        backend: this.backend,
        compiler: request.compiler,
        exitCode: 1,
        log: '! Undefined control sequence',
        diagnostics: [{ severity: 'error', message: 'Undefined control sequence' }],
        durationMs: 2,
        bibliography: {
          bibtex: false,
          biberRequested: false,
          makeindex: false,
          rerun: false,
        },
      };
    }
    return {
      success: true,
      backend: this.backend,
      compiler: request.compiler,
      exitCode: 0,
      pdf: new Uint8Array([37, 80, 68, 70, 45, 49]),
      ...(this.omitSyncTex ? {} : { synctex }),
      log: 'ok',
      diagnostics: [],
      durationMs: 4,
      bibliography: {
        bibtex: false,
        biberRequested: false,
        makeindex: false,
        rerun: false,
      },
    };
  }
  cancel(): void {}
  dispose(): void {}
}

describe('ProjectPdfService', () => {
  const controllers: AbortController[] = [];

  it('BF2 keeps a Live PDF and its SyncTeX map on the same accepted generation', async () => {
    const controller = new AbortController();
    controllers.push(controller);
    const env = createTestEnvironment({ controller });
    const services = env.instantiateAll();
    const livePdf = new Uint8Array([37, 80, 68, 70, 45, 76, 73, 86, 69]);

    await services.projectPdf.acceptLivePreview('paper-live', 'main.tex', 7, livePdf, synctex);
    expect(services.projectPdf.getWorkspaceState('paper-live')).toMatchObject({
      origin: 'live',
      generation: 7,
      synctexAvailable: true,
      rootFile: 'main.tex',
    });
    expect(services.projectPdf.getWorkspaceState('paper-live')?.pdf).toEqual(livePdf);

    await services.projectPdf.acceptLivePreview(
      'paper-live',
      'main.tex',
      6,
      new Uint8Array([1, 2, 3]),
      undefined,
    );
    expect(services.projectPdf.getWorkspaceState('paper-live')?.generation).toBe(7);
    expect(services.projectPdf.getWorkspaceState('paper-live')?.synctexAvailable).toBe(true);

    services.projectPdf.clearLivePreview('paper-live');
    expect(services.projectPdf.getWorkspaceState('paper-live')).toBeUndefined();
  });
  afterEach(() => {
    for (const controller of controllers.splice(0)) controller.abort();
  });

  it('tracks explicit scroll ownership and preserves the originating user through follower motion', () => {
    const controller = new AbortController();
    controllers.push(controller);
    const env = createTestEnvironment({ controller });
    const services = env.instantiateAll();

    expect(services.projectPdf.getScrollOwnership('paper-scroll')).toBeUndefined();
    expect(services.projectPdf.isScrollOwnedByUser('paper-scroll', 'source')).toBe(false);

    services.projectPdf.claimScrollOwnership('paper-scroll', 'source-user');
    expect(services.projectPdf.getScrollOwnership('paper-scroll')).toBe('source-user');
    expect(services.projectPdf.isScrollOwnedByUser('paper-scroll', 'source')).toBe(true);
    expect(services.projectPdf.isScrollOwnedByUser('paper-scroll', 'pdf')).toBe(false);

    services.projectPdf.claimScrollOwnership('paper-scroll', 'pdf-programmatic');
    expect(services.projectPdf.getScrollOwnership('paper-scroll')).toBe('pdf-programmatic');
    expect(services.projectPdf.isScrollOwnedByUser('paper-scroll', 'source')).toBe(true);
    expect(services.projectPdf.isScrollOwnedByUser('paper-scroll', 'pdf')).toBe(false);

    services.projectPdf.claimScrollOwnership('paper-scroll', 'pdf-user');
    services.projectPdf.claimScrollOwnership('paper-scroll', 'source-programmatic');
    expect(services.projectPdf.getScrollOwnership('paper-scroll')).toBe('source-programmatic');
    expect(services.projectPdf.isScrollOwnedByUser('paper-scroll', 'pdf')).toBe(true);
    expect(services.projectPdf.isScrollOwnedByUser('paper-scroll', 'source')).toBe(false);
  });

  it('keeps the successful PDF and SyncTeX map paired and resolves multi-file navigation', async () => {
    const compiler = new SequencedCompiler();
    const controller = new AbortController();
    controllers.push(controller);
    const env = createTestEnvironment({
      controller,
      coreConfigOverrides: {
        projectCompile: () => ({ createCompiler: () => compiler }),
      },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    await services.workspaceOps.createWorkspaceInfo({
      name: 'paper',
      type: WORKSPACE_STORAGE_TYPE.Memory,
      metadata: {},
    });
    const storage = await services.projectStorage.getProvider('paper');
    await storage.writeFile(
      projectFilePath('main.tex'),
      new TextEncoder().encode('\\documentclass{article}'),
    );
    await storage.writeFile(
      projectFilePath('chapters/results.tex'),
      new TextEncoder().encode('Results'),
    );

    services.projectPdf.setActiveSourceFile(
      'paper',
      projectFilePath('chapters/results.tex'),
    );
    await services.projectCompile.compile('paper');

    await vi.waitFor(() => {
      expect(services.projectPdf.getWorkspaceState('paper')?.synctexAvailable).toBe(true);
    });
    const initial = services.projectPdf.getWorkspaceState('paper');
    expect(initial?.activeSourcePages).toEqual([1, 2]);
    expect(
      services.projectPdf.forwardSync(
        'paper',
        projectFilePath('chapters/results.tex'),
        80,
        1,
        { force: true },
      )?.page,
    ).toBe(2);
    await expect(
      services.projectPdf.reverseSync('paper', 1, 40, 55, { force: true }),
    ).resolves.toMatchObject({
      projectPath: 'chapters/results.tex',
      line: 42,
    });

    compiler.shouldFail = true;
    await services.projectCompile.compile('paper');
    const afterFailure = services.projectPdf.getWorkspaceState('paper');
    expect(afterFailure?.pdf).toEqual(initial?.pdf);
    expect(afterFailure?.synctexAvailable).toBe(true);

    compiler.shouldFail = false;
    compiler.omitSyncTex = true;
    await services.projectCompile.compile('paper');
    await vi.waitFor(() => {
      expect(services.projectPdf.getWorkspaceState('paper')?.synctexAvailable).toBe(
        false,
      );
    });
    expect(services.projectPdf.getWorkspaceState('paper')?.activeSourcePages).toEqual(
      [],
    );
    expect(
      services.projectPdf.getWorkspaceState('paper')?.synctexUnavailableReason,
    ).toContain('did not emit a SyncTeX map');
  });

  it('persists page, scroll, zoom, fit mode and output tab per workspace without mutating workspace metadata', async () => {
    const values = new Map<string, string>();
    const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
    Object.defineProperty(globalThis, 'localStorage', {
      configurable: true,
      value: {
        getItem: (key: string) => values.get(key) ?? null,
        setItem: (key: string, value: string) => values.set(key, value),
        removeItem: (key: string) => values.delete(key),
        clear: () => values.clear(),
      },
    });
    try {
      const controller = new AbortController();
      controllers.push(controller);
      const env = createTestEnvironment({ controller });
      const services = env.instantiateAll();
      await env.mountAll();
      await services.workspaceOps.createWorkspaceInfo({
        name: 'paper-view',
        type: WORKSPACE_STORAGE_TYPE.Memory,
        metadata: { untouched: true },
      });

      expect(await services.projectPdf.getViewState('paper-view')).toMatchObject({
        page: 1,
        fitMode: 'width',
        outputTab: 'pdf',
      });
      services.projectPdf.updateViewState('paper-view', {
        page: 7,
        scrollTop: 1337,
        zoom: 1.6,
        fitMode: 'custom',
        outputTab: 'log',
      });
      await new Promise((resolve) => setTimeout(resolve, 220));

      const metadata = await services.workspaceOps.getWorkspaceMetadata('paper-view');
      expect(metadata).toEqual({ untouched: true });

      // A workspace-info change clears the runtime cache; the next read must
      // still restore the browser-local view preference.
      await services.workspaceOps.updateWorkspaceMetadata('paper-view', (current) => ({
        ...current,
        touchedLater: true,
      }));
      await vi.waitFor(async () => {
        expect(await services.projectPdf.getViewState('paper-view')).toMatchObject({
          page: 7,
          scrollTop: 1337,
          zoom: 1.6,
          fitMode: 'custom',
          outputTab: 'log',
        });
      });
    } finally {
      if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage);
      else delete (globalThis as { localStorage?: Storage }).localStorage;
    }
  });
});
