import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as tar from 'tar-stream';
import { createGzip } from 'zlib';
import { Readable } from 'stream';
import { ConfigService } from '@nestjs/config';

// `archiver` v8 ships as ESM only, which ts-jest cannot parse when StorageService
// is imported transitively. These tests never exercise the export path (which is
// the only consumer of archiver), so a lightweight stub is sufficient.
jest.mock('archiver', () => ({ default: jest.fn() }));

import { StorageService } from './storage.service';

/** Build an in-memory gzipped tar archive from the given entries. */
function makeTarGz(entries: { name: string; data: string }[]): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const pack = tar.pack();
    let i = 0;
    const writeNext = (): void => {
      if (i >= entries.length) {
        pack.finalize();
        return;
      }
      const entry = entries[i++];
      pack.entry({ name: entry.name }, Buffer.from(entry.data), err => (err ? reject(err) : writeNext()));
    };
    const gzip = createGzip();
    const chunks: Buffer[] = [];
    pack.pipe(gzip);
    gzip.on('data', (c: Buffer) => chunks.push(c));
    gzip.on('end', () => resolve(Buffer.concat(chunks)));
    gzip.on('error', reject);
    writeNext();
  });
}

describe('StorageService (local) path traversal protection', () => {
  let baseDir: string;
  let localPath: string;
  let service: StorageService;

  beforeEach(() => {
    baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-storage-'));
    localPath = path.join(baseDir, 'media');
    const configService = {
      get: (key: string) => {
        if (key === 'storage.type') return 'local';
        if (key === 'storage.localPath') return localPath;
        return undefined;
      },
    } as unknown as ConfigService;
    service = new StorageService(configService);
  });

  afterEach(() => {
    fs.rmSync(baseDir, { recursive: true, force: true });
  });

  it('writes a file within the storage root', async () => {
    await service.putFile('sub/ok.txt', Buffer.from('hi'));
    expect(fs.readFileSync(path.join(localPath, 'sub/ok.txt'), 'utf8')).toBe('hi');
  });

  it('rejects writing a file outside the storage root', async () => {
    await expect(service.putFile('../escape.txt', Buffer.from('x'))).rejects.toThrow();
    expect(fs.existsSync(path.join(baseDir, 'escape.txt'))).toBe(false);
  });

  it('rejects reading a file outside the storage root', async () => {
    // A real file that exists OUTSIDE the storage root; without containment the
    // service would happily read it via "..".
    fs.writeFileSync(path.join(baseDir, 'secret.txt'), 'topsecret');
    await expect(service.getFile('../secret.txt')).rejects.toThrow();
  });

  it('deletes a file within the storage root', async () => {
    await service.putFile('sub/gone.txt', Buffer.from('bye'));
    await service.deleteFile('sub/gone.txt');
    expect(fs.existsSync(path.join(localPath, 'sub/gone.txt'))).toBe(false);
  });

  it('deleting an already-missing file resolves without throwing', async () => {
    await expect(service.deleteFile('never-existed.txt')).resolves.toBeUndefined();
  });

  it('rejects deleting a file outside the storage root', async () => {
    fs.writeFileSync(path.join(baseDir, 'secret.txt'), 'topsecret');
    await expect(service.deleteFile('../secret.txt')).rejects.toThrow();
    expect(fs.existsSync(path.join(baseDir, 'secret.txt'))).toBe(true);
  });

  it('imports safe entries but refuses tar entries that escape the storage root', async () => {
    const gz = await makeTarGz([
      { name: 'safe.txt', data: 'good' },
      { name: '../evil.txt', data: 'bad' },
    ]);

    const count = await service.importFromStream(Readable.from(gz));

    expect(fs.readFileSync(path.join(localPath, 'safe.txt'), 'utf8')).toBe('good');
    expect(fs.existsSync(path.join(baseDir, 'evil.txt'))).toBe(false);
    expect(count).toBe(1);
  });
});

function makeLocalService(): { service: StorageService; baseDir: string; localPath: string } {
  const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-storage-'));
  const localPath = path.join(baseDir, 'media');
  const configService = {
    get: (key: string) => (key === 'storage.type' ? 'local' : key === 'storage.localPath' ? localPath : undefined),
  } as unknown as ConfigService;
  return { service: new StorageService(configService), baseDir, localPath };
}

describe('StorageService put/getFile containment is backend-agnostic', () => {
  // Force S3 routing with a stub client so the assertion proves the guard runs BEFORE any S3 call
  // (i.e. it lives in put/getFile, so the otherwise-unguarded S3 backend is contained too).
  function s3Stub(service: StorageService): jest.Mock {
    const sendMock = jest.fn();
    const internal = service as unknown as { storageType: string; s3Client: unknown; s3Available: boolean };
    internal.storageType = 's3';
    internal.s3Client = { send: sendMock };
    internal.s3Available = true;
    return sendMock;
  }

  it('putFile rejects an unsafe key before reaching the S3 backend', async () => {
    const { service, baseDir } = makeLocalService();
    const sendMock = s3Stub(service);

    await expect(service.putFile('../evil', Buffer.from('x'))).rejects.toThrow();
    expect(sendMock).not.toHaveBeenCalled();

    fs.rmSync(baseDir, { recursive: true, force: true });
  });

  it('getFile rejects an unsafe key before reaching the S3 backend', async () => {
    const { service, baseDir } = makeLocalService();
    const sendMock = s3Stub(service);

    await expect(service.getFile('../../etc/passwd')).rejects.toThrow();
    expect(sendMock).not.toHaveBeenCalled();

    fs.rmSync(baseDir, { recursive: true, force: true });
  });
});

describe('StorageService getFileCount (S3 size)', () => {
  it('sums the real Size of each S3 object instead of estimating', async () => {
    const { service, baseDir } = makeLocalService();
    const sendMock = jest.fn().mockResolvedValue({
      Contents: [
        { Key: 'media/a.jpg', Size: 1000 },
        { Key: 'media/b.jpg', Size: 2500 },
      ],
    });
    const internal = service as unknown as {
      storageType: string;
      s3Client: unknown;
      s3Bucket: string;
      s3Available: boolean;
    };
    internal.storageType = 's3';
    internal.s3Client = { send: sendMock };
    internal.s3Bucket = 'test-bucket';
    internal.s3Available = true;

    const result = await service.getFileCount();

    expect(result.count).toBe(2);
    expect(result.sizeBytes).toBe(3500); // real object sizes, not files.length * 100000

    fs.rmSync(baseDir, { recursive: true, force: true });
  });
});

describe('StorageService import resource caps (decompression-bomb defense)', () => {
  let baseDir: string;
  let localPath: string;
  let service: StorageService;

  beforeEach(() => {
    ({ service, baseDir, localPath } = makeLocalService());
  });

  afterEach(() => {
    fs.rmSync(baseDir, { recursive: true, force: true });
    delete process.env.STORAGE_IMPORT_MAX_BYTES;
    delete process.env.STORAGE_IMPORT_MAX_ENTRIES;
  });

  it('aborts an entry that exceeds the per-entry byte cap, writing nothing', async () => {
    process.env.STORAGE_IMPORT_MAX_BYTES = '8';
    const gz = await makeTarGz([{ name: 'bomb.bin', data: 'far-more-than-eight-bytes' }]);

    await expect(service.importFromStream(Readable.from(gz))).rejects.toThrow(/byte|cap|exceed|large/i);
    expect(fs.existsSync(path.join(localPath, 'bomb.bin'))).toBe(false);
  });

  it('aborts when the archive exceeds the max entry count', async () => {
    process.env.STORAGE_IMPORT_MAX_ENTRIES = '1';
    const gz = await makeTarGz([
      { name: 'a.txt', data: 'a' },
      { name: 'b.txt', data: 'b' },
    ]);

    await expect(service.importFromStream(Readable.from(gz))).rejects.toThrow(/entr/i);
  });

  it('aborts a large multi-chunk entry mid-stream (the payload spans several stream chunks)', async () => {
    process.env.STORAGE_IMPORT_MAX_BYTES = '1024';
    // 256 KiB easily spans multiple 64 KiB stream chunks, so this proves the running accumulator
    // aborts mid-stream rather than only after the whole entry is buffered.
    const gz = await makeTarGz([{ name: 'big.bin', data: 'x'.repeat(256 * 1024) }]);

    await expect(service.importFromStream(Readable.from(gz))).rejects.toThrow(/byte|cap|exceed|large/i);
    expect(fs.existsSync(path.join(localPath, 'big.bin'))).toBe(false);
  });

  it('imports normally within the (generous default) caps', async () => {
    const gz = await makeTarGz([{ name: 'ok.txt', data: 'fine' }]);
    const count = await service.importFromStream(Readable.from(gz));
    expect(count).toBe(1);
  });
});

describe('StorageService import stream error handling (request fails, process survives)', () => {
  let baseDir: string;
  let service: StorageService;

  beforeEach(() => {
    ({ service, baseDir } = makeLocalService());
  });

  afterEach(() => {
    fs.rmSync(baseDir, { recursive: true, force: true });
  });

  it("rejects on a non-gzip input instead of crashing on gunzip's unhandled error event", async () => {
    // zlib emits 'error' on the gunzip stream for a corrupt/non-gzip payload; pipe() does not forward
    // it, so without a listener on gunzip this would take down the whole process.
    const notGzip = Readable.from([Buffer.from('this is definitely not a gzip stream')]);
    await expect(service.importFromStream(notGzip)).rejects.toThrow();
  });

  it('rejects when the input stream itself errors mid-read', async () => {
    const failing = new Readable({
      read() {
        this.destroy(new Error('read boom'));
      },
    });
    await expect(service.importFromStream(failing)).rejects.toThrow(/read boom/);
  });
});

describe('StorageService local traversal (async + bounded)', () => {
  let baseDir: string;
  let service: StorageService;

  beforeEach(() => {
    ({ service, baseDir } = makeLocalService());
  });

  afterEach(() => {
    fs.rmSync(baseDir, { recursive: true, force: true });
    delete process.env.STORAGE_LIST_MAX_FILES;
  });

  it('lists files across nested subdirectories (async traversal)', async () => {
    await service.putFile('a.txt', Buffer.from('a'));
    await service.putFile('sub/b.txt', Buffer.from('b'));
    await service.putFile('sub/deep/c.txt', Buffer.from('c'));

    const files = await service.listFiles();
    expect(files.sort()).toEqual(['a.txt', 'sub/b.txt', 'sub/deep/c.txt']);
  });

  it('stops at the STORAGE_LIST_MAX_FILES cap instead of enumerating a huge tree', async () => {
    process.env.STORAGE_LIST_MAX_FILES = '5';
    for (let i = 0; i < 20; i++) {
      await service.putFile(`file${i}.txt`, Buffer.from('x'));
    }

    const files = await service.listFiles();
    expect(files.length).toBe(5); // capped, not 20
  });

  it('iterateFiles enumerates the full tree, ignoring the STORAGE_LIST_MAX_FILES per-call cap', async () => {
    process.env.STORAGE_LIST_MAX_FILES = '5';
    for (let i = 0; i < 20; i++) {
      await service.putFile(`file${i}.txt`, Buffer.from('x'));
    }

    const seen: string[] = [];
    for await (const file of service.iterateFiles()) seen.push(file);
    expect(seen.length).toBe(20); // complete where listFiles() above truncates at 5
  });
});

/**
 * The export enumerated with listFiles(), which stops at STORAGE_LIST_MAX_FILES and returns without
 * logging or throwing — so the documented local→S3 migration (export, repoint STORAGE_TYPE, import)
 * silently left media behind, and the operator's own files/count pre-check was truncated by the same
 * path. iterateFiles() exists for exactly this case: its own doc calls the cap "a per-call DoS guard,
 * not a completeness contract" and tells callers needing the whole store to iterate instead.
 */
describe('StorageService.createExportStream enumerates the whole store', () => {
  it('walks the uncapped iterator rather than the capped listing', async () => {
    const { service } = makeLocalService();
    const listFiles = jest.spyOn(service, 'listFiles');
    const iterateFiles = jest.spyOn(service, 'iterateFiles').mockImplementation(async function* () {
      yield await Promise.resolve('media/a.bin');
    });
    jest.spyOn(service, 'getFile').mockResolvedValue(Buffer.from('x'));

    // The enumerator runs before the archive is constructed, so which one was used is settled even
    // if archiving itself cannot run in this environment. That is the whole claim here.
    await service.createExportStream().catch(() => undefined);

    expect(iterateFiles).toHaveBeenCalled();
    expect(listFiles).not.toHaveBeenCalled();
  });

  /**
   * The count is the OTHER half of the same fix and had no test of its own — reverting it to the
   * capped listing left the suite green. It is the pre-check an operator runs before that migration,
   * so a count truncated at the cap hides precisely the gap they are checking for, and hides it
   * while agreeing with itself.
   */
  it('counts with the uncapped iterator too, so the pre-check cannot hide the gap', async () => {
    const { service } = makeLocalService();
    const listFiles = jest.spyOn(service, 'listFiles');
    const iterateFiles = jest.spyOn(service, 'iterateFiles').mockImplementation(async function* () {
      yield await Promise.resolve('media/a.bin');
      yield await Promise.resolve('media/b.bin');
    });

    const { count } = await service.getFileCount();

    expect(iterateFiles).toHaveBeenCalled();
    expect(listFiles).not.toHaveBeenCalled();
    expect(count).toBe(2); // the iterator's items are what was counted, not an unrelated walk
  });

  /**
   * Removing the cap from the enumeration also removed the bound on the SIZE loop underneath it,
   * which stat'ed every file synchronously. On a large store that holds the event loop for the whole
   * walk: health checks, webhooks and every in-flight request wait behind a count.
   *
   * Measured, not asserted structurally. A single "did something run during the call?" flag is NOT
   * discriminating here — the enumeration awaits before the stat loop begins, so that flag flips
   * either way. Counting how many times the loop yields WHILE the call is pending does discriminate:
   * measured at 1 tick for 2000 files with the synchronous loop, and it rises with the file count
   * once each stat yields.
   */
  it('does not hold the event loop for the whole walk', async () => {
    const { service, localPath } = makeLocalService();
    fs.mkdirSync(localPath, { recursive: true });
    const FILES = 2000;
    for (let i = 0; i < FILES; i++) {
      fs.writeFileSync(path.join(localPath, `f${i}.bin`), 'x');
    }

    let ticks = 0;
    let finished = false;
    const tick = (): void => {
      if (finished) return;
      ticks += 1;
      setImmediate(tick);
    };
    setImmediate(tick);

    const { count } = await service.getFileCount();
    finished = true;

    expect(count).toBe(FILES); // the walk really did the work being measured
    expect(ticks).toBeGreaterThan(100);
  }, 30000);
});
