/**
 * Tests for realm RPC client + host.
 *
 * Uses a fake `MessagePort` pair (two `RealmPortLike` shims wired
 * to each other) so we can drive both ends of the protocol in
 * vitest without real workers / iframes.
 *
 * Critical assertion: the fetch channel routes through `ctx.fetch`
 * (just-bash `SecureFetch`), NOT `globalThis.fetch`. Without that,
 * masked secret values would bypass the proxy.
 */

import { describe, it, expect, vi } from 'vitest';
import type { CommandContext, IFileSystem, FsStat } from 'just-bash';
import { RealmRpcClient } from '../../../src/kernel/realm/realm-rpc.js';
import { attachRealmHost } from '../../../src/kernel/realm/realm-host.js';
import type { RealmPortLike } from '../../../src/kernel/realm/realm-rpc.js';

interface PortPair {
  realm: RealmPortLike;
  host: RealmPortLike;
}

function makePortPair(): PortPair {
  const realmListeners = new Set<(event: MessageEvent) => void>();
  const hostListeners = new Set<(event: MessageEvent) => void>();
  const realm: RealmPortLike = {
    postMessage: (msg) => {
      // Posts FROM realm go TO host.
      for (const h of [...hostListeners]) h({ data: msg } as MessageEvent);
    },
    addEventListener: (_type, handler) => {
      realmListeners.add(handler);
    },
    removeEventListener: (_type, handler) => {
      realmListeners.delete(handler);
    },
  };
  const host: RealmPortLike = {
    postMessage: (msg) => {
      // Posts FROM host go TO realm.
      for (const h of [...realmListeners]) h({ data: msg } as MessageEvent);
    },
    addEventListener: (_type, handler) => {
      hostListeners.add(handler);
    },
    removeEventListener: (_type, handler) => {
      hostListeners.delete(handler);
    },
  };
  return { realm, host };
}

function makeMockFs(files: Record<string, string> = {}): IFileSystem {
  const store = new Map<string, string>(Object.entries(files));
  const fs: IFileSystem = {
    async readFile(path: string) {
      const content = store.get(path);
      if (content === undefined) throw new Error(`ENOENT: ${path}`);
      return content;
    },
    async readFileBuffer(path: string) {
      const content = store.get(path);
      if (content === undefined) throw new Error(`ENOENT: ${path}`);
      return new TextEncoder().encode(content);
    },
    async writeFile(path: string, content: string | Uint8Array) {
      store.set(path, typeof content === 'string' ? content : new TextDecoder().decode(content));
    },
    async appendFile() {
      /* noop */
    },
    async exists(path: string) {
      return store.has(path);
    },
    async stat(path: string): Promise<FsStat> {
      if (!store.has(path)) throw new Error(`ENOENT: ${path}`);
      return {
        isFile: true,
        isDirectory: false,
        isSymbolicLink: false,
        mode: 0o644,
        size: (store.get(path) || '').length,
        mtime: new Date(),
      };
    },
    async mkdir() {
      /* noop */
    },
    async readdir() {
      return [...store.keys()];
    },
    async rm(path: string) {
      store.delete(path);
    },
    async cp() {
      /* noop */
    },
    async mv() {
      /* noop */
    },
    resolvePath(base: string, path: string): string {
      if (path.startsWith('/')) return path;
      return base === '/' ? `/${path}` : `${base}/${path}`;
    },
    getAllPaths() {
      return [...store.keys()];
    },
    async chmod() {
      /* noop */
    },
    async symlink() {
      /* noop */
    },
    async link() {
      /* noop */
    },
    async readlink() {
      return '';
    },
    async lstat(path: string) {
      return fs.stat(path);
    },
    async realpath(path: string) {
      return path;
    },
    async utimes() {
      /* noop */
    },
  };
  return fs;
}

function makeCtx(overrides: Partial<CommandContext> = {}): CommandContext {
  return {
    fs: makeMockFs(),
    cwd: '/workspace',
    env: new Map(),
    stdin: '',
    ...overrides,
  } as CommandContext;
}

describe('realm RPC: vfs channel', () => {
  it('round-trips readFile through ctx.fs.resolvePath + ctx.fs.readFile', async () => {
    const ctx = makeCtx({ fs: makeMockFs({ '/workspace/data.txt': 'hello' }) });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);
    const result = await client.call<string>('vfs', 'readFile', ['data.txt']);
    expect(result).toBe('hello');
    client.dispose();
  });

  it('writeFile persists through to ctx.fs', async () => {
    const fs = makeMockFs();
    const ctx = makeCtx({ fs });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);
    await client.call('vfs', 'writeFile', ['/tmp/out.txt', 'written']);
    expect(await fs.readFile('/tmp/out.txt')).toBe('written');
    client.dispose();
  });

  it('readDir returns entries from ctx.fs', async () => {
    const ctx = makeCtx({ fs: makeMockFs({ '/a': '1', '/b': '2' }) });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);
    const entries = await client.call<string[]>('vfs', 'readDir', ['/']);
    expect(entries).toEqual(expect.arrayContaining(['/a', '/b']));
    client.dispose();
  });

  it('rejects unknown vfs ops with a clear error', async () => {
    const ctx = makeCtx();
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);
    await expect(client.call('vfs', 'unknownOp', [])).rejects.toThrow(/unknown vfs op/);
    client.dispose();
  });
});

describe('realm RPC: vfs walkTree + writeBatch', () => {
  function makeHierarchicalFs(): IFileSystem {
    // `dirs` is a separate set so an empty directory still shows up
    // in readdir of its parent (the flat `store` mock didn't model that).
    // Files are stored as raw bytes so binary content round-trips.
    const files = new Map<string, Uint8Array>();
    const dirs = new Set<string>(['/']);
    function ensureParents(path: string): void {
      let cursor = path;
      while (cursor !== '/' && cursor.length > 0) {
        const slash = cursor.lastIndexOf('/');
        cursor = slash <= 0 ? '/' : cursor.slice(0, slash);
        dirs.add(cursor);
      }
    }
    const fs: IFileSystem = {
      async readFile(path) {
        const bytes = files.get(path);
        if (bytes === undefined) throw new Error(`ENOENT: ${path}`);
        return new TextDecoder().decode(bytes);
      },
      async readFileBuffer(path) {
        const bytes = files.get(path);
        if (bytes === undefined) throw new Error(`ENOENT: ${path}`);
        // Return a fresh copy so the caller can't mutate the
        // backing store (mirrors VirtualFS's defensive semantics).
        return new Uint8Array(bytes);
      },
      async writeFile(path, content) {
        const bytes =
          typeof content === 'string' ? new TextEncoder().encode(content) : new Uint8Array(content);
        files.set(path, bytes);
        ensureParents(path);
      },
      async appendFile() {},
      async exists(path) {
        return files.has(path) || dirs.has(path);
      },
      async stat(path) {
        if (files.has(path)) {
          return {
            isFile: true,
            isDirectory: false,
            isSymbolicLink: false,
            mode: 0o644,
            size: files.get(path)!.length,
            mtime: new Date(),
          };
        }
        if (dirs.has(path)) {
          return {
            isFile: false,
            isDirectory: true,
            isSymbolicLink: false,
            mode: 0o755,
            size: 0,
            mtime: new Date(),
          };
        }
        throw new Error(`ENOENT: ${path}`);
      },
      async mkdir(path) {
        dirs.add(path);
        ensureParents(path);
      },
      async readdir(path) {
        if (!dirs.has(path)) throw new Error(`ENOENT: ${path}`);
        const out = new Set<string>();
        const prefix = path === '/' ? '/' : `${path}/`;
        for (const f of files.keys()) {
          if (!f.startsWith(prefix)) continue;
          const rest = f.slice(prefix.length);
          const slash = rest.indexOf('/');
          out.add(slash === -1 ? rest : rest.slice(0, slash));
        }
        for (const d of dirs) {
          if (!d.startsWith(prefix) || d === path) continue;
          const rest = d.slice(prefix.length);
          const slash = rest.indexOf('/');
          out.add(slash === -1 ? rest : rest.slice(0, slash));
        }
        return [...out];
      },
      async rm() {},
      async cp() {},
      async mv() {},
      resolvePath(base, path) {
        if (path.startsWith('/')) return path;
        return base === '/' ? `/${path}` : `${base}/${path}`;
      },
      getAllPaths() {
        return [...files.keys()];
      },
      async chmod() {},
      async symlink() {},
      async link() {},
      async readlink() {
        return '';
      },
      async lstat(path) {
        return fs.stat(path);
      },
      async realpath(path) {
        return path;
      },
      async utimes() {},
    };
    // Seed with a few useful paths for tests. `__seed` takes a
    // string (UTF-8 encoded into storage); `__seedBinary` takes raw
    // bytes so tests can validate the binary round-trip path.
    const fsWithSeed = fs as IFileSystem & {
      __seed: (path: string, content: string) => void;
      __seedBinary: (path: string, bytes: Uint8Array) => void;
    };
    fsWithSeed.__seed = (path, content) => {
      files.set(path, new TextEncoder().encode(content));
      ensureParents(path);
    };
    fsWithSeed.__seedBinary = (path, bytes) => {
      files.set(path, new Uint8Array(bytes));
      ensureParents(path);
    };
    return fs;
  }

  function decode(bytes: Uint8Array | undefined): string | undefined {
    return bytes ? new TextDecoder().decode(bytes) : undefined;
  }

  it('walkTree returns every entry under the root in a single RPC', async () => {
    const fs = makeHierarchicalFs();
    const seed = (fs as IFileSystem & { __seed?: (p: string, c: string) => void }).__seed!;
    seed('/workspace/a.txt', 'A');
    seed('/workspace/sub/b.txt', 'BB');
    seed('/workspace/sub/c.txt', 'CCC');
    const ctx = makeCtx({ fs });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);

    const entries = await client.call<
      Array<{ path: string; isDir: boolean; size?: number; content?: Uint8Array }>
    >('vfs', 'walkTree', ['/workspace']);
    const byPath = new Map(entries.map((e) => [e.path, e]));
    expect(byPath.get('/workspace/sub')).toMatchObject({ isDir: true });
    expect(byPath.get('/workspace/a.txt')).toMatchObject({ isDir: false, size: 1 });
    expect(decode(byPath.get('/workspace/a.txt')!.content)).toBe('A');
    expect(decode(byPath.get('/workspace/sub/b.txt')!.content)).toBe('BB');
    expect(decode(byPath.get('/workspace/sub/c.txt')!.content)).toBe('CCC');
    client.dispose();
  });

  it('walkTree carries binary file content byte-for-byte (no UTF-8 coercion)', async () => {
    // PNG signature + a few non-UTF-8 bytes. The old string-typed
    // walkTree would TextDecoder() these and mojibake the file.
    const pngHeader = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe]);
    const fs = makeHierarchicalFs();
    const seedBin = (fs as IFileSystem & { __seedBinary?: (p: string, b: Uint8Array) => void })
      .__seedBinary!;
    seedBin('/data/image.png', pngHeader);
    const ctx = makeCtx({ fs });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);

    const entries = await client.call<
      Array<{ path: string; isDir: boolean; size?: number; content?: Uint8Array }>
    >('vfs', 'walkTree', ['/data']);
    const png = entries.find((e) => e.path === '/data/image.png')!;
    expect(png.content).toBeInstanceOf(Uint8Array);
    expect(Array.from(png.content!)).toEqual(Array.from(pngHeader));
    client.dispose();
  });

  it('walkTree honors maxFileBytes — large files are listed without content', async () => {
    const fs = makeHierarchicalFs();
    const seed = (fs as IFileSystem & { __seed?: (p: string, c: string) => void }).__seed!;
    seed('/data/small.txt', 'xx');
    seed('/data/large.bin', 'x'.repeat(1024));
    const ctx = makeCtx({ fs });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);

    const entries = await client.call<
      Array<{ path: string; isDir: boolean; size?: number; content?: Uint8Array }>
    >('vfs', 'walkTree', ['/data', { maxFileBytes: 100 }]);
    const small = entries.find((e) => e.path === '/data/small.txt');
    const large = entries.find((e) => e.path === '/data/large.bin');
    expect(decode(small?.content)).toBe('xx');
    expect(large?.content).toBeUndefined();
    expect(large?.size).toBe(1024);
    client.dispose();
  });

  it('walkTree returns an empty list for a missing directory rather than throwing', async () => {
    const fs = makeHierarchicalFs();
    const ctx = makeCtx({ fs });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);

    const entries = await client.call<unknown[]>('vfs', 'walkTree', ['/does-not-exist']);
    expect(entries).toEqual([]);
    client.dispose();
  });

  it('writeBatch creates mkdirs first, writes files, and reports an empty failure list', async () => {
    const fs = makeHierarchicalFs();
    const ctx = makeCtx({ fs });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);

    const enc = new TextEncoder();
    const result = await client.call<{
      ok: true;
      failedMkdirs: Array<{ path: string; error: string }>;
      failedFiles: Array<{ path: string; error: string }>;
    }>('vfs', 'writeBatch', [
      {
        mkdirs: ['/workspace/new-dir'],
        files: [
          { path: '/workspace/new-dir/hello.txt', content: enc.encode('hi') },
          { path: '/workspace/top.txt', content: enc.encode('top') },
        ],
      },
    ]);
    expect(result.ok).toBe(true);
    expect(result.failedMkdirs).toEqual([]);
    expect(result.failedFiles).toEqual([]);
    expect(await fs.readFile('/workspace/new-dir/hello.txt')).toBe('hi');
    expect(await fs.readFile('/workspace/top.txt')).toBe('top');
    client.dispose();
  });

  it('writeBatch reports per-entry write failures instead of silently swallowing them', async () => {
    // One file rejects, the other succeeds. The realm needs to see
    // both — silently dropping the failed one is what made Python
    // outputs "vanish" through the post-sync path.
    const fs = makeHierarchicalFs();
    const enc = new TextEncoder();
    const originalWrite = fs.writeFile.bind(fs);
    fs.writeFile = vi.fn(async (path: string, content: string | Uint8Array) => {
      if (path === '/workspace/poison.txt') throw new Error('EACCES: denied');
      return originalWrite(path, content);
    }) as IFileSystem['writeFile'];
    const ctx = makeCtx({ fs });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);

    const result = await client.call<{
      ok: true;
      failedMkdirs: Array<{ path: string; error: string }>;
      failedFiles: Array<{ path: string; error: string }>;
    }>('vfs', 'writeBatch', [
      {
        files: [
          { path: '/workspace/ok.txt', content: enc.encode('good') },
          { path: '/workspace/poison.txt', content: enc.encode('lost') },
        ],
      },
    ]);
    expect(result.ok).toBe(true);
    expect(result.failedFiles).toHaveLength(1);
    expect(result.failedFiles[0].path).toBe('/workspace/poison.txt');
    expect(result.failedFiles[0].error).toContain('EACCES');
    expect(await fs.readFile('/workspace/ok.txt')).toBe('good');
    client.dispose();
  });

  it('writeBatch is a no-op when both lists are empty', async () => {
    const fs = makeHierarchicalFs();
    const ctx = makeCtx({ fs });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);
    const result = await client.call<{ ok: true }>('vfs', 'writeBatch', [{}]);
    expect(result.ok).toBe(true);
    client.dispose();
  });
});

describe('realm RPC: exec channel', () => {
  it('routes exec call through ctx.exec', async () => {
    const exec = vi.fn().mockResolvedValue({ stdout: 'output\n', stderr: '', exitCode: 0 });
    const ctx = makeCtx({ exec });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);
    const result = await client.call<{
      stdout: string;
      stderr: string;
      exitCode: number;
    }>('exec', 'run', ['echo hi']);
    expect(result).toEqual({ stdout: 'output\n', stderr: '', exitCode: 0 });
    expect(exec).toHaveBeenCalledWith('echo hi', { cwd: '/workspace' });
    client.dispose();
  });

  it('errors clearly when ctx.exec is missing', async () => {
    const ctx = makeCtx({ exec: undefined });
    const { realm, host } = makePortPair();
    attachRealmHost(host, ctx);
    const client = new RealmRpcClient(realm);
    await expect(client.call('exec', 'run', ['ls'])).rejects.toThrow(/exec is not available/);
    client.dispose();
  });
});

describe('realm RPC: fetch channel', () => {
  it('routes fetch through ctx.fetch (NOT globalThis.fetch) — secret invariant', async () => {
    // Critical: secrets are substituted server-side via the
    // SecureFetch path (createNodeFetchAdapter wraps ctx.fetch).
    // If the host ever calls globalThis.fetch directly, masked
    // secret values get sent to upstream APIs literally and break
    // every secret-gated call. Pin the routing here.
    const ctxFetch = vi.fn().mockResolvedValue({
      url: 'https://example.com/',
      status: 200,
      statusText: 'OK',
      headers: { 'content-type': 'text/plain' },
      body: 'response-bytes',
    });
    const ctx = makeCtx({ fetch: ctxFetch });
    const globalFetch = vi.fn();
    const originalFetch = globalThis.fetch;
    (globalThis as { fetch: typeof globalThis.fetch }).fetch =
      globalFetch as unknown as typeof globalThis.fetch;
    try {
      const { realm, host } = makePortPair();
      attachRealmHost(host, ctx);
      const client = new RealmRpcClient(realm);
      const result = await client.call<{
        status: number;
        statusText: string;
        headers: Record<string, string>;
        body: Uint8Array;
        url: string;
      }>('fetch', 'request', ['https://example.com/']);
      expect(ctxFetch).toHaveBeenCalled();
      expect(globalFetch).not.toHaveBeenCalled();
      expect(result.status).toBe(200);
      // Body bytes round-trip cleanly.
      expect(new TextDecoder().decode(result.body)).toBe('response-bytes');
      client.dispose();
    } finally {
      (globalThis as { fetch: typeof globalThis.fetch }).fetch = originalFetch;
    }
  });

  it('falls back to globalThis.fetch when ctx.fetch is absent', async () => {
    const fakeResponse = new Response('global-bytes', {
      status: 201,
      statusText: 'Created',
      headers: { 'x-custom': 'yes' },
    });
    const globalFetch = vi.fn().mockResolvedValue(fakeResponse);
    const originalFetch = globalThis.fetch;
    (globalThis as { fetch: typeof globalThis.fetch }).fetch =
      globalFetch as unknown as typeof globalThis.fetch;
    try {
      const ctx = makeCtx({ fetch: undefined });
      const { realm, host } = makePortPair();
      attachRealmHost(host, ctx);
      const client = new RealmRpcClient(realm);
      const result = await client.call<{
        status: number;
        body: Uint8Array;
      }>('fetch', 'request', ['https://example.com/']);
      expect(globalFetch).toHaveBeenCalled();
      expect(result.status).toBe(201);
      expect(new TextDecoder().decode(result.body)).toBe('global-bytes');
      client.dispose();
    } finally {
      (globalThis as { fetch: typeof globalThis.fetch }).fetch = originalFetch;
    }
  });
});

describe('realm RPC: client lifecycle', () => {
  it('rejects pending calls on dispose', async () => {
    const { realm } = makePortPair();
    // No host attached — the request hangs forever otherwise.
    const client = new RealmRpcClient(realm);
    const pending = client.call('vfs', 'readFile', ['/x']);
    client.dispose();
    await expect(pending).rejects.toThrow(/disposed/);
  });

  it('rejects new calls after dispose', async () => {
    const { realm } = makePortPair();
    const client = new RealmRpcClient(realm);
    client.dispose();
    await expect(client.call('vfs', 'readFile', ['/x'])).rejects.toThrow(/disposed/);
  });
});
