import { NOOP_SYNC_LOGGER } from '@sp/sync-core';
import { OpLog } from '../../core/log';
import { DecompressError } from '../core/errors/sync-errors';
import {
  compressWithGzip,
  compressWithGzipToString,
  decompressGzipFromString,
} from './compression-handler';

describe('compression-handler', () => {
  beforeEach(() => {
    spyOn(OpLog, 'err').and.stub();
    spyOn(OpLog, 'log').and.stub();
    spyOn(console, 'log').and.stub();
  });

  describe('compressWithGzipToString / decompressGzipFromString', () => {
    it('compresses and decompresses strings losslessly', async () => {
      const payload = {
        text: 'Hello 😀',
        numbers: [1, 2, 3],
        nested: { active: true, count: 42 },
      };
      const serialized = JSON.stringify(payload);

      const compressed = await compressWithGzipToString(serialized);
      expect(typeof compressed).toBe('string');
      expect(compressed.startsWith('data:')).toBeFalse();

      const decompressed = await decompressGzipFromString(compressed);

      expect(decompressed).toEqual(serialized);
      expect(JSON.parse(decompressed)).toEqual(payload);
    });

    it('throws DecompressError when base64 cannot be decoded', async () => {
      // 'invalid-base64!@#' contains characters not valid in base64
      await expectAsync(
        decompressGzipFromString('invalid-base64!@#'),
      ).toBeRejectedWithError(DecompressError);
    });

    it('throws DecompressError when data is valid base64 but not valid gzip', async () => {
      // Valid base64 but not gzip data
      const invalidGzip = btoa('not gzip data');
      await expectAsync(decompressGzipFromString(invalidGzip)).toBeRejectedWithError(
        DecompressError,
      );
    });

    it('logs only sanitized error metadata on decompression failure', async () => {
      (OpLog.err as jasmine.Spy).calls.reset();
      const invalidGzip = btoa('not gzip data');

      await expectAsync(decompressGzipFromString(invalidGzip)).toBeRejectedWithError(
        DecompressError,
      );

      expect(OpLog.err).toHaveBeenCalledOnceWith(
        '[compression-handler] gzip decompression failed',
        jasmine.objectContaining({ name: jasmine.any(String) }),
        {
          inputLength: invalidGzip.length,
          sanitizedInputLength: invalidGzip.length,
        },
      );
      expect((OpLog.err as jasmine.Spy).calls.mostRecent().args[1] instanceof Error)
        .withContext('logged error identity must be sanitized')
        .toBeFalse();
      const additionalLogText = (OpLog.log as jasmine.Spy).calls
        .allArgs()
        .flat()
        .join('\n');
      expect(additionalLogText).not.toContain('not gzip data');
    });

    it('preserves DecompressError when the injected logger throws', async () => {
      const throwingLogger = {
        ...NOOP_SYNC_LOGGER,
        err: () => {
          throw new Error('logger failed');
        },
      };

      await expectAsync(
        decompressGzipFromString(btoa('not gzip data'), throwingLogger),
      ).toBeRejectedWithError(DecompressError);
    });

    // Issue #6581: decompressGzipFromString should sanitize base64 input
    // to handle data corruption from WebDAV servers, partial uploads, etc.

    it('gives DecompressError (not atob error) for truncated base64 (len%4=1)', async () => {
      const original = JSON.stringify({ task: 'test', data: 'x'.repeat(200) });
      const compressed = await compressWithGzipToString(original);
      // Remove chars to create len%4=1 — previously threw opaque atob() error
      const truncLen = compressed.length - (compressed.length % 4 || 4) + 1;
      const truncated = compressed.slice(0, truncLen);
      expect(truncated.length % 4).toBe(1);
      // Should throw DecompressError from gzip (not from atob parsing)
      await expectAsync(decompressGzipFromString(truncated)).toBeRejectedWithError(
        DecompressError,
      );
    });

    it('decompresses base64 with leading BOM from WebDAV', async () => {
      const original = JSON.stringify({ task: 'test' });
      const compressed = await compressWithGzipToString(original);
      const withBOM = '\ufeff' + compressed;
      const result = await decompressGzipFromString(withBOM);
      expect(result).toEqual(original);
    });

    it('decompresses base64 with non-breaking space appended', async () => {
      const original = JSON.stringify({ task: 'test' });
      const compressed = await compressWithGzipToString(original);
      const withNBSP = compressed + '\u00a0';
      const result = await decompressGzipFromString(withNBSP);
      expect(result).toEqual(original);
    });

    it('decompresses base64 with embedded zero-width space', async () => {
      const original = JSON.stringify({ task: 'test' });
      const compressed = await compressWithGzipToString(original);
      const withZWS = compressed.slice(0, 5) + '\u200b' + compressed.slice(5);
      const result = await decompressGzipFromString(withZWS);
      expect(result).toEqual(original);
    });

    it('still throws DecompressError for genuinely corrupt data', async () => {
      await expectAsync(
        decompressGzipFromString('!!!completely-broken-data!!!'),
      ).toBeRejectedWithError(DecompressError);
    });

    // Issue #7300: opaque "compressed Input was truncated" browser errors
    // must be translated into actionable guidance directing the user to
    // delete the corrupt remote file.
    it('surfaces recovery guidance in DecompressError message for truncated gzip', async () => {
      const original = JSON.stringify({ task: 'test', data: 'x'.repeat(1024) });
      const compressed = await compressWithGzipToString(original);
      // Remove the gzip footer (last few bytes in base64) to simulate a
      // truncated remote file. The underlying DecompressionStream will throw
      // "compressed Input was truncated" or similar.
      const truncLen = compressed.length - (compressed.length % 4 || 4);
      const truncated = compressed.slice(0, truncLen - 4); // drop a full 4-char group

      let caught: unknown;
      try {
        await decompressGzipFromString(truncated);
      } catch (e) {
        caught = e;
      }
      expect(caught).toBeInstanceOf(DecompressError);
      const message = (caught as Error).message.toLowerCase();
      // Truncation branch must have fired: guidance names the file to delete.
      expect(message).toContain('sync-data.json');
      expect(message).toContain('delete');
    });
  });

  describe('compressWithGzip (binary)', () => {
    it('should compress a string to gzip bytes with valid header', async () => {
      const input = 'Hello, World!';

      const result = await compressWithGzip(input);

      expect(result).toBeInstanceOf(Uint8Array);
      expect(result.length).toBeGreaterThan(0);
      // Gzip magic number: 0x1f, 0x8b
      expect(result[0]).toBe(0x1f);
      expect(result[1]).toBe(0x8b);
    });

    it('should produce smaller output for repetitive data', async () => {
      const repetitiveInput = 'AAAA'.repeat(1000); // 4000 bytes of 'A'

      const result = await compressWithGzip(repetitiveInput);

      // Compressed should be much smaller than original
      expect(result.length).toBeLessThan(repetitiveInput.length / 10);
    });

    it('should handle empty string', async () => {
      const input = '';

      const result = await compressWithGzip(input);

      expect(result).toBeInstanceOf(Uint8Array);
      // Even empty string produces gzip header
      expect(result.length).toBeGreaterThan(0);
    });

    it('should handle unicode characters', async () => {
      const input = '日本語テスト 🎉 émojis';

      const result = await compressWithGzip(input);

      expect(result).toBeInstanceOf(Uint8Array);
      expect(result[0]).toBe(0x1f);
      expect(result[1]).toBe(0x8b);
    });

    it('should achieve good compression for JSON payloads', async () => {
      const jsonPayload = JSON.stringify({
        tasks: Array.from({ length: 100 }, (_, i) => ({
          id: `task-${i}`,
          title: `Task number ${i} with some longer description`,
          done: i % 2 === 0,
        })),
      });

      const result = await compressWithGzip(jsonPayload);

      // JSON typically compresses well - should be less than 50% of original
      expect(result.length).toBeLessThan(jsonPayload.length * 0.5);
    });

    it('should produce valid gzip that can be decompressed by DecompressionStream', async () => {
      const original = 'Test payload for round-trip verification';

      const compressed = await compressWithGzip(original);

      // Decompress using DecompressionStream (same API used by server-side Node.js zlib)
      const stream = new DecompressionStream('gzip');
      const writer = stream.writable.getWriter();
      writer.write(compressed as BufferSource);
      writer.close();
      const decompressed = await new Response(stream.readable).arrayBuffer();
      const decoded = new TextDecoder().decode(decompressed);

      expect(decoded).toBe(original);
    });
  });
});
