import { describe, expect, it } from 'vitest';
import {
  decodeProjectZip,
  encodeProjectZip,
  exportProjectZip,
  exportProjectZipStream,
  importProjectZip,
  importProjectZipBlob,
  MemoryProjectStorage,
  projectFilePath,
} from '../index';

const encoder = new TextEncoder();

describe('project ZIP portability', () => {
  it('round trips text, binary, nested, hidden and extensionless files', async () => {
    const files = [
      {
        path: projectFilePath('main.tex'),
        data: encoder.encode('\\documentclass{article}'),
      },
      { path: projectFilePath('Makefile'), data: encoder.encode('all:') },
      {
        path: projectFilePath('.git/HEAD'),
        data: encoder.encode('ref: refs/heads/main'),
      },
      {
        path: projectFilePath('figures/plot.pdf'),
        data: new Uint8Array([0, 255, 12, 44]),
      },
    ];

    const archive = encodeProjectZip(files);
    const decoded = await decodeProjectZip(archive);

    expect(decoded).toEqual(files);
  });

  it('rejects traversal paths before writing storage', async () => {
    const safeArchive = encodeProjectZip([
      { path: projectFilePath('main.tex'), data: encoder.encode('safe') },
    ]);
    const malicious = safeArchive.slice();
    const view = new DataView(malicious.buffer);
    let centralOffset = 0;
    for (let offset = 0; offset <= malicious.byteLength - 4; offset += 1) {
      if (view.getUint32(offset, true) === 0x02014b50) {
        centralOffset = offset;
        break;
      }
    }
    const maliciousName = encoder.encode('../x.tex');
    malicious.set(maliciousName, 30);
    malicious.set(maliciousName, centralOffset + 46);

    await expect(decodeProjectZip(malicious)).rejects.toThrow(
      'Invalid project file path',
    );
  });

  it('rejects duplicate project paths in decoded archives', async () => {
    const archive = encodeProjectZip([
      { path: projectFilePath('main.tex'), data: encoder.encode('first') },
      { path: projectFilePath('more.tex'), data: encoder.encode('second') },
    ]);
    const duplicate = archive.slice();
    const view = new DataView(duplicate.buffer);
    const centralOffsets: number[] = [];

    for (let offset = 0; offset <= duplicate.byteLength - 4; offset += 1) {
      if (view.getUint32(offset, true) === 0x02014b50) {
        centralOffsets.push(offset);
      }
    }

    expect(centralOffsets).toHaveLength(2);
    const secondCentral = centralOffsets[1];
    if (secondCentral === undefined) {
      throw new Error('Expected second central-directory entry');
    }

    // Keep the replacement length identical so only the semantic duplicate
    // path changes; the archive structure remains valid.
    const duplicateName = encoder.encode('main.tex');
    duplicate.set(duplicateName, secondCentral + 46);

    // The local header filename must match its central-directory filename.
    const secondLocalOffset = view.getUint32(secondCentral + 42, true);
    duplicate.set(duplicateName, secondLocalOffset + 30);

    await expect(decodeProjectZip(duplicate)).rejects.toThrow(
      'duplicate project path: main.tex',
    );
  });

  it('refuses to encode duplicate project paths', () => {
    expect(() =>
      encodeProjectZip([
        { path: projectFilePath('main.tex'), data: encoder.encode('first') },
        { path: projectFilePath('main.tex'), data: encoder.encode('second') },
      ]),
    ).toThrow('duplicate project path: main.tex');
  });

  it('exports normal project ZIPs without Git metadata by default', async () => {
    const storage = new MemoryProjectStorage();
    await storage.writeFile(projectFilePath('main.tex'), encoder.encode('hello'));
    await storage.writeFile(projectFilePath('.git/HEAD'), encoder.encode('ref'));

    const archive = await exportProjectZip(storage);
    expect((await decodeProjectZip(archive)).map((file) => file.path)).toEqual([
      'main.tex',
    ]);
  });


  it('streams ZIP export without collecting project files before encoding', async () => {
    const storage = new MemoryProjectStorage();
    await storage.writeFile(projectFilePath('main.tex'), encoder.encode('hello'));
    await storage.writeFile(projectFilePath('sections/a.tex'), encoder.encode('section'));

    const stream = await exportProjectZipStream(storage);
    const archive = new Uint8Array(await new Response(stream).arrayBuffer());
    expect((await decodeProjectZip(archive)).map((file) => file.path)).toEqual([
      'main.tex',
      'sections/a.tex',
    ]);
  });

  it('imports directly from a Blob one entry at a time', async () => {
    const archive = encodeProjectZip([
      { path: projectFilePath('main.tex'), data: encoder.encode('hello') },
      { path: projectFilePath('figures/a.png'), data: new Uint8Array([1, 2, 3]) },
    ]);
    const storage = new MemoryProjectStorage();
    await importProjectZipBlob(new Blob([archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer]), storage);
    expect(await storage.readFile(projectFilePath('main.tex'))).toEqual(encoder.encode('hello'));
    expect(await storage.readFile(projectFilePath('figures/a.png'))).toEqual(new Uint8Array([1, 2, 3]));
  });

  it('imports atomically into an empty project', async () => {
    const archive = encodeProjectZip([
      { path: projectFilePath('main.tex'), data: encoder.encode('hello') },
      { path: projectFilePath('figures/a.png'), data: new Uint8Array([1, 2, 3]) },
    ]);
    const storage = new MemoryProjectStorage();

    await importProjectZip(archive, storage);

    expect(await storage.readFile(projectFilePath('main.tex'))).toEqual(
      encoder.encode('hello'),
    );
    expect(await storage.readFile(projectFilePath('figures/a.png'))).toEqual(
      new Uint8Array([1, 2, 3]),
    );
  });

  it('refuses an import before writing when the project has a conflicting file', async () => {
    const archive = encodeProjectZip([
      { path: projectFilePath('main.tex'), data: encoder.encode('incoming') },
      { path: projectFilePath('new.tex'), data: encoder.encode('new') },
    ]);
    const storage = new MemoryProjectStorage();
    await storage.writeFile(
      projectFilePath('main.tex'),
      encoder.encode('existing'),
    );

    await expect(importProjectZip(archive, storage)).rejects.toThrow(
      'Cannot import ZIP over existing project file: main.tex',
    );
    expect(await storage.readFile(projectFilePath('main.tex'))).toEqual(
      encoder.encode('existing'),
    );
    expect(await storage.readFile(projectFilePath('new.tex'))).toBeUndefined();
  });

  it('rolls back files already written when a later import write fails', async () => {
    class FailingStorage extends MemoryProjectStorage {
      override async writeFile(
        path: ReturnType<typeof projectFilePath>,
        data: Uint8Array,
        signal?: AbortSignal,
      ): Promise<void> {
        if (path === 'second.tex') {
          throw new Error('simulated storage failure');
        }
        await super.writeFile(path, data, signal);
      }
    }

    const archive = encodeProjectZip([
      { path: projectFilePath('first.tex'), data: encoder.encode('first') },
      { path: projectFilePath('second.tex'), data: encoder.encode('second') },
    ]);
    const storage = new FailingStorage();

    await expect(importProjectZip(archive, storage)).rejects.toThrow(
      'simulated storage failure',
    );
    expect(await storage.readFile(projectFilePath('first.tex'))).toBeUndefined();
    expect(
      await storage.readFile(projectFilePath('second.tex')),
    ).toBeUndefined();
  });

  it('honours cancellation before an import mutates project storage', async () => {
    const archive = encodeProjectZip([
      { path: projectFilePath('main.tex'), data: encoder.encode('hello') },
    ]);
    const storage = new MemoryProjectStorage();
    const controller = new AbortController();
    controller.abort(new Error('cancelled'));

    await expect(
      importProjectZip(archive, storage, { signal: controller.signal }),
    ).rejects.toThrow('cancelled');
    expect(await storage.readFile(projectFilePath('main.tex'))).toBeUndefined();
  });

  it('round-trips a large browser project with 1,200 small files', async () => {
    const files = Array.from({ length: 1_200 }, (_, index) => ({
      path: projectFilePath(`sections/section-${index}.tex`),
      data: encoder.encode(`Section ${index}\n`),
    }));

    const archive = encodeProjectZip(files);
    const decoded = await decodeProjectZip(archive);

    expect(decoded).toHaveLength(files.length);
    expect(decoded[0]?.path).toBe('sections/section-0.tex');
    expect(decoded.at(-1)?.path).toBe('sections/section-1199.tex');
  });

  it('rejects archives larger than the configured browser safety limit', async () => {
    const archive = encodeProjectZip([
      { path: projectFilePath('main.tex'), data: encoder.encode('hello') },
    ]);

    await expect(
      decodeProjectZip(archive, {
        maxArchiveBytes: archive.byteLength - 1,
        maxEntries: 10,
        maxFileBytes: 1024,
        maxUncompressedBytes: 1024,
        maxCompressionRatio: 20,
      }),
    ).rejects.toThrow('maximum archive size');
  });
});
