import { describe, expect, test } from 'bun:test';
import { createShade } from '@shade/sdk';
import {
  createPrekeyServer,
  MemoryPrekeyStore,
  PrekeyServerEvents,
} from '@shade/server';
import { SubtleCryptoProvider } from '@shade/crypto-web';

const crypto = new SubtleCryptoProvider();

/**
 * Stand up a prekey server + two Shades + Bob's file handler + RPC route
 * mounted on Bun.serve, then return Alice's HTTP-only `FileClient`.
 *
 * Mirrors the request-response setup a browser client would use against a
 * Bun-style server.
 */
async function setupHttpRig(opts: {
  bobHandler: Parameters<NonNullable<Awaited<ReturnType<typeof createShade>>['files']>['serve']>[0];
}) {
  // 1. Prekey server.
  const prekeyEvents = new PrekeyServerEvents();
  const prekey = createPrekeyServer({
    crypto,
    store: new MemoryPrekeyStore(),
    disableRateLimit: true,
    events: prekeyEvents,
  });
  const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch });
  const prekeyUrl = `http://localhost:${prekeyServer.port}`;

  // 2. Two Shades. Alice plays the browser client (no transferRoute);
  //    Bob is the server.
  const alice = await createShade({ prekeyServer: prekeyUrl, address: 'alice' });
  const bob = await createShade({ prekeyServer: prekeyUrl, address: 'bob' });

  // 3. Bob: register file handler (HTTP-only — no streams) + mount
  //    the RPC route.
  await bob.files.serve(opts.bobHandler, { inlineOnly: true });
  const rpcRoute = bob.files.rpcRoute({ acceptFirstMessage: true });
  const bobServer = Bun.serve({ port: 0, fetch: rpcRoute.fetch });
  const rpcUrl = `http://localhost:${bobServer.port}/rpc`;

  // 5. Alice: build the HTTP-only file client.
  const fs = alice.files.httpClient('bob', { rpcUrl, defaultTimeoutMs: 5000 });

  return {
    alice,
    bob,
    fs,
    rpcUrl,
    teardown: async () => {
      await alice.shutdown();
      await bob.shutdown();
      bobServer.stop();
      prekeyServer.stop();
    },
  };
}

describe('@shade/files HTTP RPC — round-trip', () => {
  test('list → mkdir → stat → write inline → read inline → delete via httpClient', async () => {
    interface VfsEntry {
      kind: 'file' | 'dir';
      bytes?: Uint8Array;
      contentType?: string;
    }
    const vfs = new Map<string, VfsEntry>([
      ['/', { kind: 'dir' }],
      ['/photos', { kind: 'dir' }],
    ]);

    const rig = await setupHttpRig({
      bobHandler: {
        list: async (ctx) => {
          const prefix = ctx.path.endsWith('/') ? ctx.path : `${ctx.path}/`;
          const entries = Array.from(vfs.entries())
            .filter(([p]) => p.startsWith(prefix) && p !== ctx.path && !p.slice(prefix.length).includes('/'))
            .map(([p, e]) => ({
              name: p.slice(prefix.length) || p,
              kind: e.kind,
              size: e.bytes?.byteLength ?? 0,
              mtime: 0,
              metadata: {},
            }));
          return { entries, hasMore: false };
        },
        stat: async (ctx) => {
          const e = vfs.get(ctx.path);
          if (!e) throw new (await import('../../src/index.js')).NotFoundError(`stat ${ctx.path}`);
          return {
            name: ctx.path.split('/').pop() ?? ctx.path,
            kind: e.kind,
            size: e.bytes?.byteLength ?? 0,
            mtime: 0,
            metadata: {},
            ...(e.contentType !== undefined ? { contentType: e.contentType } : {}),
          };
        },
        mkdir: async (ctx) => {
          vfs.set(ctx.path, { kind: 'dir' });
          return { entry: { name: ctx.path.split('/').pop() ?? ctx.path, kind: 'dir' as const, size: 0, mtime: 0, metadata: {} } };
        },
        delete: async (ctx) => {
          if (!vfs.has(ctx.path)) {
            throw new (await import('../../src/index.js')).NotFoundError(`delete ${ctx.path}`);
          }
          vfs.delete(ctx.path);
          return { deletedCount: 1 };
        },
        read: async (ctx) => {
          const e = vfs.get(ctx.path);
          if (!e || e.kind !== 'file' || !e.bytes) {
            throw new (await import('../../src/index.js')).NotFoundError(`read ${ctx.path}`);
          }
          // Omit sha256 — dispatcher computes it from the bytes.
          return {
            kind: 'inline' as const,
            bytes: e.bytes,
            ...(e.contentType !== undefined ? { contentType: e.contentType } : {}),
          };
        },
        write: async (ctx) => {
          if (ctx.args.content.kind !== 'inline') {
            throw new (await import('../../src/index.js')).ConflictError('streams not supported in this test handler');
          }
          vfs.set(ctx.args.path, {
            kind: 'file',
            bytes: ctx.args.content.bytes,
            ...(ctx.args.contentType !== undefined ? { contentType: ctx.args.contentType } : {}),
          });
          return {
            entry: {
              name: ctx.args.path.split('/').pop() ?? ctx.args.path,
              kind: 'file' as const,
              size: ctx.args.content.bytes.byteLength,
              mtime: 0,
              metadata: {},
              ...(ctx.args.contentType !== undefined ? { contentType: ctx.args.contentType } : {}),
            },
          };
        },
      },
    });

    try {
      // list
      const listed = await rig.fs.list('/');
      expect(listed.entries.map((e) => e.name).sort()).toContain('photos');

      // mkdir
      await rig.fs.mkdir('/docs');
      const stat = await rig.fs.stat('/docs');
      expect(stat.kind).toBe('dir');

      // write inline
      const payload = new TextEncoder().encode('hello browser-friendly world');
      const writeResult = await rig.fs.write('/docs/greeting.txt', payload, {
        contentType: 'text/plain',
      });
      expect(writeResult.entry.size).toBe(payload.byteLength);

      // read inline
      const readResult = await rig.fs.read('/docs/greeting.txt');
      expect(readResult.kind).toBe('inline');
      if (readResult.kind === 'inline') {
        expect(new TextDecoder().decode(readResult.bytes)).toBe('hello browser-friendly world');
        expect(readResult.contentType).toBe('text/plain');
      }

      // delete
      const del = await rig.fs.delete('/docs/greeting.txt');
      expect(del.deletedCount).toBe(1);

      // stat the deleted path → typed NotFoundError
      const { NotFoundError } = await import('../../src/index.js');
      await expect(rig.fs.stat('/docs/greeting.txt')).rejects.toBeInstanceOf(NotFoundError);
    } finally {
      await rig.teardown();
    }
  });

  test('streamed write (> 256 KiB) is rejected with a clear error', async () => {
    const rig = await setupHttpRig({
      bobHandler: {
        write: async () => ({
          entry: { name: 'unused', kind: 'file' as const, size: 0, mtime: 0, metadata: {} },
        }),
      },
    });
    try {
      const big = new Uint8Array(257 * 1024);
      const { ConflictError } = await import('../../src/index.js');
      await expect(rig.fs.write('/big.bin', big)).rejects.toBeInstanceOf(ConflictError);
    } finally {
      await rig.teardown();
    }
  });

  test('rpcRoute() throws when no handler is attached', async () => {
    // Don't call shade.files.serve(...) — rpcRoute() should refuse.
    const prekeyEvents = new PrekeyServerEvents();
    const prekey = createPrekeyServer({
      crypto,
      store: new MemoryPrekeyStore(),
      disableRateLimit: true,
      events: prekeyEvents,
    });
    const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch });
    const bob = await createShade({
      prekeyServer: `http://localhost:${prekeyServer.port}`,
      address: 'bob',
    });
    try {
      expect(() => bob.files.rpcRoute()).toThrow(/no handler attached/);
    } finally {
      await bob.shutdown();
      prekeyServer.stop();
    }
  });

  test('missing X-Shade-Sender-Address header → 400', async () => {
    const rig = await setupHttpRig({
      bobHandler: {
        stat: async () => ({
          name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {},
        }),
      },
    });
    try {
      const res = await fetch(rig.rpcUrl, {
        method: 'POST',
        body: new Uint8Array([0]),
      });
      expect(res.status).toBe(400);
      const body = (await res.json()) as { error: string };
      expect(body.error).toMatch(/X-Shade-Sender-Address/);
    } finally {
      await rig.teardown();
    }
  });

  test('empty body → 400', async () => {
    const rig = await setupHttpRig({
      bobHandler: {
        stat: async () => ({
          name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {},
        }),
      },
    });
    try {
      const res = await fetch(rig.rpcUrl, {
        method: 'POST',
        headers: { 'X-Shade-Sender-Address': 'alice' },
      });
      expect(res.status).toBe(400);
    } finally {
      await rig.teardown();
    }
  });

  test('garbage body → 401 decrypt failure', async () => {
    const rig = await setupHttpRig({
      bobHandler: {
        stat: async () => ({
          name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {},
        }),
      },
    });
    try {
      const res = await fetch(rig.rpcUrl, {
        method: 'POST',
        headers: { 'X-Shade-Sender-Address': 'alice' },
        body: new Uint8Array([0x02, 0xff, 0xff, 0xff]),
      });
      // 400 from envelope decode failure or 401 from decrypt failure.
      expect([400, 401]).toContain(res.status);
    } finally {
      await rig.teardown();
    }
  });

  test('body past maxBodyBytes → 413', async () => {
    const prekeyEvents = new PrekeyServerEvents();
    const prekey = createPrekeyServer({
      crypto,
      store: new MemoryPrekeyStore(),
      disableRateLimit: true,
      events: prekeyEvents,
    });
    const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch });
    const bob = await createShade({
      prekeyServer: `http://localhost:${prekeyServer.port}`,
      address: 'bob',
    });
    await bob.files.serve(
      {
        stat: async () => ({
          name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {},
        }),
      },
      { inlineOnly: true },
    );
    const route = bob.files.rpcRoute({ maxBodyBytes: 1024 });
    const server = Bun.serve({ port: 0, fetch: route.fetch });
    try {
      const big = new Uint8Array(2048);
      const res = await fetch(`http://localhost:${server.port}/rpc`, {
        method: 'POST',
        headers: { 'X-Shade-Sender-Address': 'alice' },
        body: big,
      });
      expect(res.status).toBe(413);
    } finally {
      await bob.shutdown();
      server.stop();
      prekeyServer.stop();
    }
  });
});
