import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { sha256 } from '@noble/hashes/sha2.js';
import {
  NotFoundError,
  downloadDirectory,
  createMemoryDirectory,
  walk,
  type DirectoryHandleLike,
  type FileEntry,
} from '../../src/index.js';
import { setupFileRig, type FileTestRig } from './helpers/rig.js';

interface StoredFile {
  bytes: Uint8Array;
  contentType?: string;
  sha256: string;
}

function bytesToHex(b: Uint8Array): string {
  return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}

async function streamToBytes(s: ReadableStream<Uint8Array>): Promise<Uint8Array> {
  const reader = s.getReader();
  const parts: Uint8Array[] = [];
  let total = 0;
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    if (value === undefined) continue;
    parts.push(value);
    total += value.byteLength;
  }
  reader.releaseLock();
  const out = new Uint8Array(total);
  let offset = 0;
  for (const p of parts) {
    out.set(p, offset);
    offset += p.byteLength;
  }
  return out;
}

function streamFromBytes(bytes: Uint8Array): ReadableStream<Uint8Array> {
  return new ReadableStream<Uint8Array>({
    start(controller) {
      controller.enqueue(bytes);
      controller.close();
    },
  });
}

describe('downloadDirectory — bulk download from remote', () => {
  let rig: FileTestRig;
  const blobs = new Map<string, StoredFile>();
  const dirs = new Set<string>();

  beforeAll(async () => {
    blobs.clear();
    dirs.clear();
    // Build remote tree:
    //   /src/
    //   ├── small.txt    ('hello world\n', 12 bytes)
    //   ├── img.bin      (50 KiB random)
    //   └── nested/
    //       ├── big.bin  (400 KiB random)
    //       └── tiny.bin (3 bytes)
    dirs.add('/');
    dirs.add('/src');
    dirs.add('/src/nested');
    const small = new TextEncoder().encode('hello world\n');
    const mid = new Uint8Array(50 * 1024); crypto.getRandomValues(mid);
    const big = new Uint8Array(400 * 1024); crypto.getRandomValues(big);
    const tiny = new Uint8Array([1, 2, 3]);
    blobs.set('/src/small.txt', { bytes: small, sha256: bytesToHex(sha256(small)), contentType: 'text/plain' });
    blobs.set('/src/img.bin', { bytes: mid, sha256: bytesToHex(sha256(mid)) });
    blobs.set('/src/nested/big.bin', { bytes: big, sha256: bytesToHex(sha256(big)) });
    blobs.set('/src/nested/tiny.bin', { bytes: tiny, sha256: bytesToHex(sha256(tiny)) });

    rig = await setupFileRig({
      list: async (ctx) => {
        if (!dirs.has(ctx.path)) throw new NotFoundError(ctx.path);
        const entries: FileEntry[] = [];
        const dirPrefix = ctx.path === '/' ? '/' : ctx.path + '/';
        // Subdirs
        for (const d of dirs) {
          if (d === ctx.path) continue;
          if (!d.startsWith(dirPrefix)) continue;
          const rest = d.slice(dirPrefix.length);
          if (rest.includes('/')) continue;
          entries.push({ name: rest, kind: 'dir', size: 0, mtime: 0, metadata: {} });
        }
        // Files
        for (const [path, blob] of blobs) {
          if (!path.startsWith(dirPrefix)) continue;
          const rest = path.slice(dirPrefix.length);
          if (rest.includes('/')) continue;
          entries.push({
            name: rest,
            kind: 'file',
            size: blob.bytes.byteLength,
            mtime: 0,
            ...(blob.contentType !== undefined ? { contentType: blob.contentType } : {}),
            metadata: {},
          });
        }
        return { entries, hasMore: false };
      },
      read: async (ctx) => {
        const blob = blobs.get(ctx.path);
        if (blob === undefined) throw new NotFoundError(ctx.path);
        if (blob.bytes.byteLength > 256 * 1024) {
          return blob.contentType !== undefined
            ? {
                kind: 'streams',
                stream: streamFromBytes(blob.bytes),
                size: blob.bytes.byteLength,
                sha256: blob.sha256,
                contentType: blob.contentType,
              }
            : {
                kind: 'streams',
                stream: streamFromBytes(blob.bytes),
                size: blob.bytes.byteLength,
                sha256: blob.sha256,
              };
        }
        return blob.contentType !== undefined
          ? { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256, contentType: blob.contentType }
          : { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256 };
      },
    });
  });

  afterAll(async () => {
    await rig.teardown();
  });

  test('downloads entire tree, sha256 matches per file', async () => {
    const local = createMemoryDirectory('local');
    const handle = downloadDirectory(rig.fs, '/src', local);
    const result = await handle.done();
    expect(result.filesDone).toBe(4);
    expect(result.bytesDone).toBe(12 + 50 * 1024 + 400 * 1024 + 3);

    // Verify local tree contents
    const downloadedFiles = new Map<string, Uint8Array>();
    async function dump(dir: DirectoryHandleLike, prefix: string): Promise<void> {
      for await (const [name, child] of dir.entries()) {
        const path = prefix === '' ? name : `${prefix}/${name}`;
        if (child.kind === 'directory') {
          await dump(child as DirectoryHandleLike, path);
        } else {
          const file = await (child as { getFile: () => Promise<{ arrayBuffer: () => Promise<ArrayBuffer> }> }).getFile();
          downloadedFiles.set(path, new Uint8Array(await file.arrayBuffer()));
        }
      }
    }
    await dump(local, '');
    expect(downloadedFiles.size).toBe(4);
    expect(downloadedFiles.has('small.txt')).toBe(true);
    expect(downloadedFiles.has('img.bin')).toBe(true);
    expect(downloadedFiles.has('nested/big.bin')).toBe(true);
    expect(downloadedFiles.has('nested/tiny.bin')).toBe(true);

    expect(bytesToHex(sha256(downloadedFiles.get('nested/big.bin')!))).toBe(
      blobs.get('/src/nested/big.bin')!.sha256,
    );
    expect(bytesToHex(sha256(downloadedFiles.get('img.bin')!))).toBe(
      blobs.get('/src/img.bin')!.sha256,
    );
  });

  test('aggregated progress events fire monotonically', async () => {
    const local = createMemoryDirectory('local');
    const handle = downloadDirectory(rig.fs, '/src', local);
    const progresses: { filesDone: number; bytesDone: number }[] = [];
    (async () => {
      for await (const ev of handle.events) {
        if (ev.type === 'progress') {
          progresses.push({ filesDone: ev.filesDone, bytesDone: ev.bytesDone });
        }
      }
    })().catch(() => undefined);
    await handle.done();
    await new Promise((r) => setTimeout(r, 30));
    for (let i = 1; i < progresses.length; i++) {
      expect(progresses[i]!.filesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.filesDone);
      expect(progresses[i]!.bytesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.bytesDone);
    }
  });

  test('aborts via handle.abort()', async () => {
    const local = createMemoryDirectory('local');
    const handle = downloadDirectory(rig.fs, '/src', local);
    setTimeout(() => void handle.abort('test-cancel'), 5);
    await expect(handle.done()).rejects.toThrow();
  });

  test('walk + downloadDirectory are consistent', async () => {
    const local = createMemoryDirectory('local');
    const remoteFiles: string[] = [];
    for await (const item of walk(rig.fs, '/src')) {
      if (item.entry.kind === 'file') remoteFiles.push(item.relativePath);
    }
    const handle = downloadDirectory(rig.fs, '/src', local);
    const result = await handle.done();
    expect(result.filesDone).toBe(remoteFiles.length);
  });
});
