import { afterEach, describe, expect, it, vi } from 'vitest';
import {
  persistRemoteCache,
  restoreRemoteCache,
  type RemoteCacheRunner,
  type TexliveRemoteFile,
} from '../busytex-remote-cache';

const ORIGIN = 'https://app.learnitive.test';

function keyOf(input: RequestInfo | URL): string {
  if (typeof input === 'string') {
    return input.startsWith('/') ? `${ORIGIN}${input}` : input;
  }
  if (input instanceof URL) return input.href;
  return input.url;
}

class MemoryCache {
  readonly entries = new Map<string, Response>();
  readonly deleted: string[] = [];

  async keys(): Promise<Request[]> {
    return [...this.entries.keys()].map((url) => new Request(url));
  }

  async match(input: RequestInfo | URL): Promise<Response | undefined> {
    return this.entries.get(keyOf(input))?.clone();
  }

  async delete(input: RequestInfo | URL): Promise<boolean> {
    const key = keyOf(input);
    this.deleted.push(key);
    return this.entries.delete(key);
  }

  async put(input: RequestInfo | URL, response: Response): Promise<void> {
    this.entries.set(keyOf(input), response.clone());
  }
}

describe('R14.24.1 BusyTeX remote package cache poisoning protection', () => {
  afterEach(() => vi.unstubAllGlobals());

  it('purges cached HTML masquerading as a .sty before any package is sent to TeX', async () => {
    const cache = new MemoryCache();
    const goodUrl = `${ORIGIN}/__learnitive/busytex-remote/none/good.sty`;
    const poisonedUrl = `${ORIGIN}/__learnitive/busytex-remote/none/poisoned.sty`;
    cache.entries.set(
      goodUrl,
      new Response('\\ProvidesPackage{good}\\endinput', { status: 200 }),
    );
    cache.entries.set(
      poisonedUrl,
      new Response('<!doctype html><html><body>502 upstream error</body></html>', {
        status: 200,
      }),
    );

    vi.stubGlobal('caches', {
      open: vi.fn(async () => cache as unknown as Cache),
    });

    const mounted: TexliveRemoteFile[][] = [];
    const runner: RemoteCacheRunner = {
      async writeTexliveRemoteFiles(files) {
        mounted.push(files);
      },
      async writeTexliveRemoteMisses() {},
    };

    const restored = await restoreRemoteCache(runner, 'test-assets');

    expect(restored.restoredNames).toEqual(new Set(['good.sty']));
    expect(restored.rejectedNames).toEqual(['poisoned.sty']);
    expect(mounted).toHaveLength(1);
    expect(mounted[0]?.map((file) => file.name)).toEqual(['good.sty']);
    expect(mounted[0]?.some((file) => file.name === 'poisoned.sty')).toBe(false);
    expect(cache.deleted).toContain(poisonedUrl);
    expect(cache.entries.has(poisonedUrl)).toBe(false);
  });

  it('refuses and purges newly downloaded poisoned package bytes before caching them', async () => {
    const cache = new MemoryCache();
    vi.stubGlobal('caches', {
      open: vi.fn(async () => cache as unknown as Cache),
    });

    const runner: RemoteCacheRunner = {
      async readProjectFiles() {
        return [
          {
            path: '/tmp/texlive_remote/0_good.sty',
            content: '\\ProvidesPackage{good}\\endinput',
          },
          {
            path: '/tmp/texlive_remote/0_poisoned.sty',
            content: '<html><body>not a TeX package</body></html>',
          },
          {
            path: '/tmp/texlive_remote/.misses.json',
            content: '[]',
          },
        ];
      },
    };

    const persisted = await persistRemoteCache(runner, 'test-assets');

    expect(persisted.persistedNames).toEqual(['good.sty']);
    expect(persisted.invalidNames).toEqual(['poisoned.sty']);
    expect(
      [...cache.entries.keys()].some((key) => key.endsWith('/0/good.sty')),
    ).toBe(true);
    expect(
      [...cache.entries.keys()].some((key) => key.endsWith('/0/poisoned.sty')),
    ).toBe(false);
  });

  it('skips persistence when a legacy runtime leaks project-local files into the remote-cache readback', async () => {
    const cache = new MemoryCache();
    vi.stubGlobal('caches', {
      open: vi.fn(async () => cache as unknown as Cache),
    });

    const runner: RemoteCacheRunner = {
      async readProjectFiles() {
        return [
          {
            path: 'main.tex',
            content: '<html><body>unexpected endpoint response</body></html>',
          },
          {
            path: 'main.log',
            content: 'generated project log',
          },
        ];
      },
    };

    const persisted = await persistRemoteCache(runner, 'test-assets', {
      projectFileNames: new Set(['main.tex']),
    });

    expect(persisted.scopeLeakDetected).toBe(true);
    expect(persisted.persistedNames).toEqual([]);
    expect(persisted.invalidNames).toEqual([]);
    expect(cache.entries.size).toBe(0);
  });

  it('purges poisoned generated auxiliary files instead of restoring them into kpathsea', async () => {
    const cache = new MemoryCache();
    const auxUrl = `${ORIGIN}/__learnitive/busytex-remote/26/main.aux`;
    cache.entries.set(
      auxUrl,
      new Response('<!doctype html><html><body>missing main.aux</body></html>', {
        status: 200,
      }),
    );

    vi.stubGlobal('caches', {
      open: vi.fn(async () => cache as unknown as Cache),
    });

    const mounted: TexliveRemoteFile[][] = [];
    const runner: RemoteCacheRunner = {
      async writeTexliveRemoteFiles(files) {
        mounted.push(files);
      },
      async writeTexliveRemoteMisses() {},
    };

    const restored = await restoreRemoteCache(runner, 'test-assets');

    expect(restored.restoredNames.size).toBe(0);
    expect(restored.rejectedNames).toEqual(['main.aux']);
    expect(mounted).toHaveLength(0);
    expect(cache.entries.has(auxUrl)).toBe(false);
  });

  it('never persists generated auxiliary files as TeX Live package cache entries', async () => {
    const cache = new MemoryCache();
    vi.stubGlobal('caches', {
      open: vi.fn(async () => cache as unknown as Cache),
    });

    const runner: RemoteCacheRunner = {
      async readProjectFiles() {
        return [
          {
            path: '/tmp/texlive_remote/26_main.aux',
            content: '\relax\n',
          },
        ];
      },
    };

    const persisted = await persistRemoteCache(runner, 'test-assets');
    expect(persisted.persistedNames).toEqual([]);
    expect(persisted.invalidNames).toEqual(['main.aux']);
  });

});
