import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ValidationPipe, type ArgumentMetadata } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { validateSync, type ValidationError } from 'class-validator';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AgentService } from '../../agent/agent.service.js';
import { AuthGuard } from '../../auth/auth.guard.js';
import { HarnessRegistry } from '../../harness/harness.registry.js';
import { HARNESS_REGISTRY } from '../../harness/harness.tokens.js';
import type { AuthenticatedUserLike } from '../../auth/session-scope.js';
import { SendMessageDto } from '../../conversations/conversations.dto.js';
import { ChatController } from '../chat.controller.js';
import { ChatGateway } from '../chat.gateway.js';
import { ChatRuntimeRouter } from '../chat-runtime-router.js';
import { EmbeddedChatRuntime } from '../embedded-chat.runtime.js';
import { HarnessChatRuntime } from '../harness-chat.runtime.js';
import type { ChatRuntime } from '../chat-runtime.js';
import { ChatRequestDto, HarnessTurnSendDto } from '../chat.dto.js';
import { validateSocketSession } from '../chat.gateway-auth.js';

describe('Chat controller source hardening', () => {
  it('applies AuthGuard and reads the current user', () => {
    const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');

    expect(source).toContain('@UseGuards(AuthGuard)');
    expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
    expect(source).toContain('const scope = scopeFromUser(user);');
  });
});

describe('Chat runtime routing hardening (Task Five)', () => {
  it('routes /api/chat through the exclusive ChatRuntimeRouter, never the embedded AgentService', () => {
    const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');

    // pi-rpc /api/chat must resolve execution through the one runtime router and never
    // reach into embedded agent execution. Legacy embedded behaviour lives behind
    // EmbeddedChatRuntime, reachable only via the router in legacy mode.
    expect(source).toContain('ChatRuntimeRouter');
    expect(source).not.toContain('@Inject(AgentService)');
    expect(source).not.toContain("from '../agent/agent.service.js'");
  });

  it('gateway no longer injects the embedded AgentService or RoutingEngineService', () => {
    const source = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');

    expect(source).toContain('ChatRuntimeRouter');
    expect(source).not.toContain('@Inject(AgentService)');
    expect(source).not.toContain('@Inject(RoutingEngineService)');
  });
});

describe('Harness turn:send DTO validation (Task Five, group 2 — frozen wire contract, production pipe)', () => {
  // Correction #2 (Scrappy fe3e02): drive PLAIN wire payloads through the EXACT production
  // validation the gateway applies to inbound bodies — the global ValidationPipe in
  // apps/gateway/src/main.ts: { whitelist, forbidNonWhitelisted, transform }. This exercises
  // the real plainToInstance transform, nested @Type/@ValidateNested recursion, and whitelist
  // stripping — the path a turn:send actually travels — rather than a hand-built class instance
  // fed to validateSync (which never runs @Type and is masked green by class-validator's
  // empty-metadata unknownValue behaviour). Anti-masking: every VALUE-rule red asserts the field
  // carries a REAL value constraint (not `whitelistValidation`/`unknownValue`), which the
  // decorator-less stub can NEVER produce; every authority-field red asserts the forbidden field
  // is rejected while a valid field is NOT — false against the stub, which over-rejects everything.
  const PRODUCTION_PIPE = () =>
    new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true });
  // Same production configuration, but hand back the raw ValidationError[] instead of throwing a
  // BadRequestException, so the test can inspect per-field constraint keys and nested children.
  const failPipe = new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    exceptionFactory: (errs: ValidationError[]) => errs as unknown as Error,
  });
  const asBody = (metatype: ArgumentMetadata['metatype']): ArgumentMetadata => ({
    type: 'body',
    metatype,
    data: '',
  });

  const UUID_V4 = '11111111-1111-4111-8111-111111111111';
  const validSelection = () => ({ harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' });
  const validPayload = () => ({
    conversationId: UUID_V4,
    content: 'hello there',
    selection: validSelection(),
    idempotencyKey: UUID_V4,
  });

  // Constraint keys that mean "the field was rejected for existing", NOT "its VALUE failed a real
  // rule". The decorator-less RED stub can only ever emit these (or nothing), so requiring a REAL
  // value constraint on a field is unmaskable until Step Three attaches the decorators.
  const NON_VALUE = new Set(['whitelistValidation', 'unknownValue']);

  // Flatten the error tree to `dotted.path -> Set<constraintKey>` (parent + nested children).
  const collect = (errors: ValidationError[], prefix = ''): Map<string, Set<string>> => {
    const map = new Map<string, Set<string>>();
    const add = (path: string, keys: Iterable<string>): void => {
      const set = map.get(path) ?? new Set<string>();
      for (const key of keys) set.add(key);
      map.set(path, set);
    };
    for (const error of errors) {
      const path = prefix ? `${prefix}.${error.property}` : error.property;
      if (error.constraints) add(path, Object.keys(error.constraints));
      if (error.children?.length) for (const [p, s] of collect(error.children, path)) add(p, s);
    }
    return map;
  };

  // Run the production pipe over a plain payload; return the ValidationError[] it raised (empty
  // when the payload is accepted).
  const errorsFor = async (
    payload: unknown,
    metatype: ArgumentMetadata['metatype'] = HarnessTurnSendDto,
  ): Promise<ValidationError[]> => {
    try {
      await failPipe.transform(payload, asBody(metatype));
      return [];
    } catch (thrown) {
      return thrown as ValidationError[];
    }
  };

  // True when `path` is rejected by a REAL value rule (IsUUID, IsNotEmpty, MaxLength, …), i.e. a
  // constraint that is not a mere existence/whitelist rejection.
  const hasValueConstraint = async (
    payload: unknown,
    path: string,
    metatype: ArgumentMetadata['metatype'] = HarnessTurnSendDto,
  ): Promise<boolean> => {
    const keys = collect(await errorsFor(payload, metatype)).get(path);
    return keys ? [...keys].some((key) => !NON_VALUE.has(key)) : false;
  };

  // Paths rejected purely for existing outside the whitelist (authority / unknown-field defence).
  const forbiddenFields = async (payload: unknown): Promise<string[]> => {
    const out: string[] = [];
    for (const [path, keys] of collect(await errorsFor(payload))) {
      if (keys.has('whitelistValidation')) out.push(path);
    }
    return out;
  };

  // --- GREEN controls: prove the production pipe machinery genuinely accepts a well-formed,
  //     already-decorated DTO AND enforces its constraints — so the group's reds below are the
  //     STUB's missing decorators, not a broken harness. Both pass today. ---
  it('GREEN control: the production pipe accepts a well-formed, decorated ChatRequestDto', async () => {
    await expect(
      PRODUCTION_PIPE().transform({ content: 'hello there' }, asBody(ChatRequestDto)),
    ).resolves.toBeTruthy();
  });

  it('GREEN control: the same production pipe rejects over-long ChatRequestDto content (engine truly enforces)', async () => {
    expect(
      await hasValueConstraint({ content: 'x'.repeat(10_001) }, 'content', ChatRequestDto),
    ).toBe(true);
  });

  // --- RED value-rule fence: each asserts the turn:send field is rejected by a REAL value rule.
  //     All fail against the decorator-less stub; they go green when Step Three adds the decorators.
  //     No validation implementation is permitted during RED collection. ---
  it('requires a conversation id (rejects a missing conversationId)', async () => {
    expect(
      await hasValueConstraint({ ...validPayload(), conversationId: undefined }, 'conversationId'),
    ).toBe(true);
  });

  it('requires a UUID conversation id (rejects a non-UUID conversationId)', async () => {
    expect(
      await hasValueConstraint(
        { ...validPayload(), conversationId: 'not-a-uuid' },
        'conversationId',
      ),
    ).toBe(true);
  });

  it('rejects empty / whitespace-only content (content is trimmed 1..10000)', async () => {
    expect(await hasValueConstraint({ ...validPayload(), content: '   ' }, 'content')).toBe(true);
  });

  it('rejects content above 10000 characters', async () => {
    expect(
      await hasValueConstraint({ ...validPayload(), content: 'x'.repeat(10_001) }, 'content'),
    ).toBe(true);
  });

  it('requires the nested selection triple (rejects a missing selection)', async () => {
    expect(await hasValueConstraint({ ...validPayload(), selection: undefined }, 'selection')).toBe(
      true,
    );
  });

  it('rejects malformed selection nesting (a non-object selection)', async () => {
    expect(
      await hasValueConstraint(
        { ...validPayload(), selection: 'pi/anthropic/claude' },
        'selection',
      ),
    ).toBe(true);
  });

  it('rejects a selection with a blank harnessId (each id must be non-empty)', async () => {
    const payload = {
      ...validPayload(),
      selection: { harnessId: '', providerId: 'anthropic', modelId: 'claude' },
    };
    expect(await hasValueConstraint(payload, 'selection.harnessId')).toBe(true);
  });

  it('rejects a selection missing the modelId', async () => {
    const payload = { ...validPayload(), selection: { harnessId: 'pi', providerId: 'anthropic' } };
    expect(await hasValueConstraint(payload, 'selection.modelId')).toBe(true);
  });

  it('requires a UUID-v4 idempotency key (rejects a missing key)', async () => {
    expect(
      await hasValueConstraint({ ...validPayload(), idempotencyKey: undefined }, 'idempotencyKey'),
    ).toBe(true);
  });

  it('rejects a non-UUID-v4 idempotency key', async () => {
    expect(
      await hasValueConstraint(
        { ...validPayload(), idempotencyKey: 'not-a-key' },
        'idempotencyKey',
      ),
    ).toBe(true);
  });

  // --- RED authority/unknown-field fence: the forbidden field is rejected while a valid field is
  //     NOT spuriously rejected. False against the stub (which over-rejects every field, including
  //     `content`); true only once Step Three whitelists the legitimate fields. ---
  it('rejects a top-level authority field (provider) without flagging valid fields', async () => {
    const forbidden = await forbiddenFields({ ...validPayload(), provider: 'openai' });
    expect(forbidden).toContain('provider');
    expect(forbidden).not.toContain('content');
  });

  it('rejects a top-level modelId authority field without flagging valid fields', async () => {
    const forbidden = await forbiddenFields({ ...validPayload(), modelId: 'gpt-5' });
    expect(forbidden).toContain('modelId');
    expect(forbidden).not.toContain('content');
  });

  it('rejects an attachments field (not part of the frozen turn:send contract)', async () => {
    const forbidden = await forbiddenFields({ ...validPayload(), attachments: [{ id: 'a1' }] });
    expect(forbidden).toContain('attachments');
    expect(forbidden).not.toContain('content');
  });
});

describe('Chat runtime routing — behavioural /api/chat fence (Task Five, group 3)', () => {
  // The router's own runtime tokens. The controller must reach chat execution ONLY through the
  // router; the embedded AgentService below is the forbidden path proven untouched.
  const embedded: ChatRuntime = { kind: 'embedded' };
  const harness: ChatRuntime = { kind: 'harness' };

  // Structurally-complete, non-sentinel conversation service; its methods are never invoked here.
  const boundConversationService = {
    attach: () => Promise.reject(new Error('unused')),
    detach: () => Promise.reject(new Error('unused')),
    send: () => Promise.reject(new Error('unused')),

    subscribeFrom: async function* () {
      throw new Error('unused');
    },
  } as unknown as HarnessConversationService;

  it('handles an /api/chat turn without invoking the embedded AgentService (behavioural, zero calls)', async () => {
    const calls = { getSession: 0, createSession: 0, onEvent: 0, prompt: 0 };
    // A spy standing in for the forbidden embedded runtime. `createSession` rejects so today's
    // controller bails immediately (never reaching the 120s agent-response wait) while still
    // recording that it reached into the embedded path — the RED anchor. Under Step Three the
    // router owns execution and this spy is never touched, so every counter stays 0 (GREEN).
    // Any masking mutation that re-enters embedded execution flips a counter and re-reds the test.
    const agentSpy = {
      getSession: () => {
        calls.getSession += 1;
        return undefined;
      },
      createSession: () => {
        calls.createSession += 1;
        return Promise.reject(new Error('spy: embedded AgentService must not be used'));
      },
      onEvent: () => {
        calls.onEvent += 1;
        return () => {};
      },
      prompt: () => {
        calls.prompt += 1;
        return Promise.resolve();
      },
    };

    const moduleRef = await Test.createTestingModule({
      controllers: [ChatController],
      providers: [
        { provide: AgentService, useValue: agentSpy },
        {
          // Provided so the Step-Three controller (which injects the router) still resolves here;
          // the real, empty registry seeded with a pi adapter keeps the router pi-rpc-ready.
          provide: HARNESS_REGISTRY,
          useFactory: () => {
            const registry = new HarnessRegistry();
            registry.register({
              id: 'pi',
              describe: () => Promise.reject(new Error('unused')),
              catalog: () => Promise.reject(new Error('unused')),
              create: () => Promise.reject(new Error('unused')),
              resume: () => Promise.reject(new Error('unused')),
            } as HarnessAdapter);
            return registry;
          },
        },
        {
          provide: ChatRuntimeRouter,
          useFactory: (registry: HarnessRegistry) =>
            new ChatRuntimeRouter(registry, boundConversationService, embedded, harness, 'pi-rpc'),
          inject: [HARNESS_REGISTRY],
        },
      ],
    })
      // ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard
      // injects AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so
      // the graph resolves and the test reds on BEHAVIOUR, not on a DI collection error.
      .overrideGuard(AuthGuard)
      .useValue({ canActivate: () => true })
      .compile();

    try {
      const controller = moduleRef.get(ChatController, { strict: false });
      const user = { id: 'user-1' } as AuthenticatedUserLike;
      try {
        await controller.chat({ content: 'route me' } as ChatRequestDto, user);
      } catch {
        // Today: SERVICE_UNAVAILABLE from the rejecting spy. Under Step Three: the router path may
        // reject on the deliberately-unbound fake conversation service. Either way the
        // embedded-call counters below are the contract, not the handler's return value.
      }
      expect(calls).toEqual({ getSession: 0, createSession: 0, onEvent: 0, prompt: 0 });
    } finally {
      await moduleRef.close();
    }
  });

  it('wires the exclusive ChatRuntimeRouter into the chat module graph (defense-in-depth source check)', () => {
    const source = readFileSync(resolve('src/chat/chat.module.ts'), 'utf8');
    // The controller can only inject the router if the module actually provides it. RED today:
    // ChatModule provides only ChatGateway. GREEN once Step Three registers ChatRuntimeRouter.
    expect(source).toContain('ChatRuntimeRouter');
  });
});

describe('WebSocket session authentication', () => {
  it('returns null when the handshake does not resolve to a session', async () => {
    const result = await validateSocketSession(
      {},
      {
        api: {
          getSession: vi.fn().mockResolvedValue(null),
        },
      },
    );

    expect(result).toBeNull();
  });

  it('returns the resolved session when Better Auth accepts the headers', async () => {
    const session = { user: { id: 'user-1' }, session: { id: 'session-1' } };

    const result = await validateSocketSession(
      { cookie: 'session=abc' },
      {
        api: {
          getSession: vi.fn().mockResolvedValue(session),
        },
      },
    );

    expect(result).toEqual(session);
  });
});

describe('Non-Discord ("Telegram-equivalent") socket ingress rejection (Task Five, both runtime modes)', () => {
  // Scrappy C adjudication regression: a non-Discord service socket — modelled as a "Telegram"
  // client presenting a handshake token the gateway does NOT honour and carrying no Better-Auth
  // session — must be DISCONNECTED at handleConnection, never gain the `discordService` trust flag
  // or any user scope, receive no manifest and no ack, and reach NEITHER the embedded runtime NOR
  // the harness. This must hold in BOTH legacy and pi-rpc modes: introducing the exclusive
  // ChatRuntimeRouter / pi-rpc path must not open a second, non-Discord service ingress. This is a
  // GREEN control (it holds on this branch and must keep holding through Step Three); no Telegram
  // production route or plugin exists or is added — the assertion is that no such surface is
  // reachable. The runtime slot is fronted with a REAL, ready ChatRuntimeRouter over
  // EmbeddedChatRuntime + HarnessChatRuntime so that any accidental dispatch would flip a spy
  // rather than silently pass; the router is never resolved because the socket is rejected first.
  let priorMode: string | undefined;
  beforeEach(() => {
    priorMode = process.env['CHAT_HARNESS_RUNTIME'];
  });
  afterEach(() => {
    if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
    else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
  });

  // A pi-ready registry so a pi-rpc router resolves the harness cleanly at onModuleInit — modelling
  // the hostile condition where the harness is live yet the non-Discord socket is still rejected.
  const readyPiRegistry = (): HarnessRegistry => {
    const registry = new HarnessRegistry();
    registry.register({
      id: 'pi',
      describe: () => Promise.reject(new Error('unused')),
      catalog: () => Promise.reject(new Error('unused')),
      create: () => Promise.reject(new Error('unused')),
      resume: () => Promise.reject(new Error('unused')),
    } as HarnessAdapter);
    return registry;
  };

  // Non-sentinel conversation service so pi-rpc onModuleInit resolves the harness (does not throw
  // conversation_service_unavailable); its methods must never be invoked on the rejection path.
  const availableConversationService = {
    attach: () => Promise.reject(new Error('unused')),
    detach: () => Promise.reject(new Error('unused')),
    send: () => Promise.reject(new Error('unused')),

    subscribeFrom: async function* () {
      throw new Error('unused');
    },
  } as unknown as HarnessConversationService;

  const readyRouter = (
    mode: 'legacy' | 'pi-rpc',
    agentService: unknown,
    harnessConversations: unknown,
  ): ChatRuntimeRouter => {
    const embedded = new EmbeddedChatRuntime(agentService as never);
    const harness = new HarnessChatRuntime(harnessConversations as never);
    const router = new ChatRuntimeRouter(
      readyPiRegistry(),
      availableConversationService,
      embedded,
      harness,
      mode,
    );
    router.onModuleInit();
    return router;
  };

  it.each(['legacy', 'pi-rpc'] as const)(
    'disconnects a Telegram-shaped unauthenticated socket and dispatches to no runtime (%s mode)',
    async (mode) => {
      process.env['CHAT_HARNESS_RUNTIME'] = mode;
      const agentService = {
        getSession: vi.fn().mockReturnValue(undefined),
        createSession: vi.fn(),
        recordMessage: vi.fn(),
        onEvent: vi.fn().mockReturnValue((): void => undefined),
        addChannel: vi.fn(),
        prompt: vi.fn().mockResolvedValue(undefined),
      };
      const harnessConversations = { append: vi.fn() };
      // Auth stub that resolves NO session for the Telegram socket's headers — the sole gate a
      // non-Discord client must pass, and does not.
      const auth = { api: { getSession: vi.fn().mockResolvedValue(null) } };
      const gateway = new ChatGateway(
        readyRouter(mode, agentService, harnessConversations) as never,
        auth as never,
        { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
        {} as never,
        {} as never,
        {} as never,
      );

      const client = {
        id: `telegram-raw-${mode}`,
        // A non-Discord service handshake: the gateway only honours `discordServiceToken`, so this
        // token is ignored, and there is no session cookie for validateSocketSession to resolve.
        handshake: { auth: { telegramServiceToken: 'ignored-non-discord-token' }, headers: {} },
        data: {} as Record<string, unknown>,
        emit: vi.fn(),
        disconnect: vi.fn(),
      };

      await gateway.handleConnection(client as never);

      // Rejected at the door: disconnected, no trust flag, no user scope, no manifest, and — the
      // Task 5 send-capability rule — no send-protocol advertisement to an unauthenticated socket.
      expect(client.disconnect).toHaveBeenCalled();
      expect(client.data.discordService).not.toBe(true);
      expect(client.data.user).toBeUndefined();
      expect(client.emit).not.toHaveBeenCalledWith('commands:manifest', expect.anything());
      expect(client.emit).not.toHaveBeenCalledWith('chat:send-capability', expect.anything());

      // Even if the ignored socket then attempts a message, it carries no scope, so the send path
      // never begins and no runtime is dispatched.
      await gateway.handleMessage(
        client as never,
        {
          conversationId: 'Nova:telegram:chat-1',
          content: 'via telegram',
        } as never,
      );

      expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
      expect(agentService.createSession).not.toHaveBeenCalled();
      expect(agentService.prompt).not.toHaveBeenCalled();
      expect(harnessConversations.append).not.toHaveBeenCalled();
    },
  );

  // A minimal authenticated-browser connection harness for the send-capability advertisement.
  const connectAuthedBrowser = async (
    mode: 'legacy' | 'pi-rpc',
    clientId: string,
  ): Promise<{ emit: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> => {
    process.env['CHAT_HARNESS_RUNTIME'] = mode;
    const agentService = {
      getSession: vi.fn().mockReturnValue(undefined),
      createSession: vi.fn(),
      recordMessage: vi.fn(),
      onEvent: vi.fn().mockReturnValue((): void => undefined),
      addChannel: vi.fn(),
      prompt: vi.fn().mockResolvedValue(undefined),
    };
    const harnessConversations = { append: vi.fn() };
    const auth = {
      api: {
        getSession: vi
          .fn()
          .mockResolvedValue({ user: { id: 'user-a' }, session: { id: 'session-a' } }),
      },
    };
    const gateway = new ChatGateway(
      readyRouter(mode, agentService, harnessConversations) as never,
      auth as never,
      { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
      { getManifest: vi.fn().mockReturnValue({ commands: [] }) } as never,
      {} as never,
      {} as never,
    );
    const client = {
      id: clientId,
      handshake: { auth: {}, headers: { cookie: 'session=abc' } },
      data: {} as Record<string, unknown>,
      emit: vi.fn(),
      disconnect: vi.fn(),
    };
    await gateway.handleConnection(client as never);
    return client as never;
  };

  it('advertises legacy-message exactly once to an authenticated browser in legacy mode (Task 5 MAJOR-1)', async () => {
    const client = await connectAuthedBrowser('legacy', 'browser-cap-legacy');

    // Legacy mode: the connected Gateway handles the `message` event, so it advertises
    // `legacy-message` — targeted, connection-bound, exactly once.
    expect(client.emit).toHaveBeenCalledWith('chat:send-capability', {
      protocol: 'legacy-message',
      connectionId: 'browser-cap-legacy',
    });
    const capabilityCalls = client.emit.mock.calls.filter(
      (call: unknown[]) => call[0] === 'chat:send-capability',
    );
    expect(capabilityCalls).toHaveLength(1);
    expect(client.disconnect).not.toHaveBeenCalled();
  });

  it('advertises unavailable (never turn-send) to an authenticated browser in pi-rpc mode (Task 5 MAJOR-1)', async () => {
    const client = await connectAuthedBrowser('pi-rpc', 'browser-cap-pirpc');

    // pi-rpc mode: the legacy `message` handler fails closed and the authenticated `turn:send`
    // handler lands in Task 15, so Task 5 advertises `unavailable` — never `turn-send`.
    expect(client.emit).toHaveBeenCalledWith('chat:send-capability', {
      protocol: 'unavailable',
      connectionId: 'browser-cap-pirpc',
    });
    const capabilityCalls = client.emit.mock.calls.filter(
      (call: unknown[]) => call[0] === 'chat:send-capability',
    );
    expect(capabilityCalls).toHaveLength(1);
    expect(capabilityCalls[0]?.[1]).not.toMatchObject({ protocol: 'turn-send' });
  });

  it.each(['legacy', 'pi-rpc'] as const)(
    'never advertises send-capability to a Discord service socket (%s mode, Task 5 MAJOR-1)',
    async (mode) => {
      process.env['CHAT_HARNESS_RUNTIME'] = mode;
      process.env['DISCORD_SERVICE_TOKEN'] = 'super-secret-discord-token';
      const agentService = {
        getSession: vi.fn().mockReturnValue(undefined),
        createSession: vi.fn(),
        recordMessage: vi.fn(),
        onEvent: vi.fn().mockReturnValue((): void => undefined),
        addChannel: vi.fn(),
        prompt: vi.fn().mockResolvedValue(undefined),
      };
      const gateway = new ChatGateway(
        readyRouter(mode, agentService, { append: vi.fn() }) as never,
        { api: { getSession: vi.fn() } } as never,
        { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
        {} as never,
        {} as never,
        {} as never,
      );
      const client = {
        id: `discord-service-${mode}`,
        handshake: { auth: { discordServiceToken: 'super-secret-discord-token' }, headers: {} },
        data: {} as Record<string, unknown>,
        emit: vi.fn(),
        disconnect: vi.fn(),
      };

      await gateway.handleConnection(client as never);

      // The trusted Discord service socket is not a browser; it never receives a browser
      // send-protocol advertisement.
      expect(client.data.discordService).toBe(true);
      expect(client.emit).not.toHaveBeenCalledWith('chat:send-capability', expect.anything());
      delete process.env['DISCORD_SERVICE_TOKEN'];
    },
  );

  // Record-and-forward instrumentation at the REAL returned-lease boundary: wrap the lease's own
  // dispatch/dispose so the test observes the Gateway's invocation counts through the real
  // ChatRuntimeRouter -> EmbeddedChatRuntime path — no canned lease, synthesized method, or shim.
  const instrumentLease = (
    router: ChatRuntimeRouter,
    order: string[],
    counters: { dispatch: number; dispose: number },
  ): void => {
    const realPrepare = router.prepareLegacySocketTurn.bind(router) as (
      ...args: unknown[]
    ) => Promise<{ ok: boolean; value?: { dispatch: () => unknown; dispose: () => unknown } }>;
    vi.spyOn(router, 'prepareLegacySocketTurn').mockImplementation((async (...args: unknown[]) => {
      const result = await realPrepare(...args);
      if (result.ok && result.value) {
        const lease = result.value;
        const realDispatch = lease.dispatch.bind(lease);
        const realDispose = lease.dispose.bind(lease);
        lease.dispatch = (): unknown => {
          counters.dispatch += 1;
          order.push('dispatch');
          return realDispatch();
        };
        lease.dispose = (): unknown => {
          counters.dispose += 1;
          return realDispose();
        };
      }
      return result;
    }) as never);
  };

  it('orders a legacy browser turn persist -> ack -> lease.dispatch -> prompt, each exactly once (Task 5 G2)', async () => {
    process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
    const order: string[] = [];
    const counters = { dispatch: 0, dispose: 0 };
    const session = {
      provider: 'configured-provider',
      modelId: 'configured-model',
      piSession: {
        thinkingLevel: 'medium',
        getAvailableThinkingLevels: (): string[] => ['medium'],
      },
    };
    const agentService = {
      getSession: vi.fn().mockReturnValue(undefined),
      createSession: vi.fn().mockResolvedValue(session),
      recordMessage: vi.fn(),
      onEvent: vi.fn().mockReturnValue((): void => undefined),
      addChannel: vi.fn(),
      removeChannel: vi.fn(),
      prompt: vi.fn().mockImplementation(async (): Promise<void> => {
        order.push('prompt');
      }),
    };
    const harnessConversations = { append: vi.fn() };
    const brain = {
      conversations: {
        findById: vi.fn().mockResolvedValue({ id: 'conversation-order-1' }),
        findMessages: vi.fn().mockResolvedValue([]),
        create: vi.fn().mockResolvedValue(undefined),
        update: vi.fn().mockResolvedValue(undefined),
        addMessage: vi.fn().mockImplementation(async (): Promise<{ id: string }> => {
          order.push('persist');
          return { id: 'message-order-1' };
        }),
      },
    };
    const router = readyRouter('legacy', agentService, harnessConversations);
    instrumentLease(router, order, counters);
    const gateway = new ChatGateway(
      router as never,
      { api: { getSession: vi.fn() } } as never,
      brain as never,
      {} as never,
      {} as never,
      {} as never,
    );
    const client = {
      id: 'browser-order-1',
      data: { user: { id: 'user-a' } },
      emit: vi.fn().mockImplementation((event: string): void => {
        if (event === 'message:ack') order.push('ack');
      }),
    };

    await gateway.handleMessage(
      client as never,
      {
        conversationId: 'conversation-order-1',
        content: 'ordered hello',
      } as never,
    );

    // The Gateway persists the user turn, THEN acks, THEN invokes the one-shot lease dispatch which
    // finally prompts. Observed at the real lease boundary: dispatch is invoked exactly once and the
    // runtime prompts exactly once.
    expect(order).toEqual(['persist', 'ack', 'dispatch', 'prompt']);
    expect(counters.dispatch).toBe(1);
    expect(agentService.prompt).toHaveBeenCalledTimes(1);
    expect(harnessConversations.append).not.toHaveBeenCalled();
  });

  it('drops a legacy browser turn on persistence failure: zero info/ack/dispatch/prompt, one clean disposal (Task 5 G2)', async () => {
    process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
    const order: string[] = [];
    const counters = { dispatch: 0, dispose: 0 };
    const unsub = vi.fn();
    const session = {
      provider: 'configured-provider',
      modelId: 'configured-model',
      piSession: {
        thinkingLevel: 'medium',
        getAvailableThinkingLevels: (): string[] => ['medium'],
      },
    };
    const agentService = {
      getSession: vi.fn().mockReturnValue(undefined),
      createSession: vi.fn().mockResolvedValue(session),
      recordMessage: vi.fn(),
      onEvent: vi.fn().mockReturnValue(unsub),
      addChannel: vi.fn(),
      removeChannel: vi.fn(),
      prompt: vi.fn().mockResolvedValue(undefined),
    };
    const harnessConversations = { append: vi.fn() };
    const brain = {
      conversations: {
        findById: vi.fn().mockResolvedValue({ id: 'conversation-fail-1' }),
        findMessages: vi.fn().mockResolvedValue([]),
        create: vi.fn().mockResolvedValue(undefined),
        update: vi.fn().mockResolvedValue(undefined),
        addMessage: vi.fn().mockRejectedValue(new Error('persistence unavailable')),
      },
    };
    const router = readyRouter('legacy', agentService, harnessConversations);
    instrumentLease(router, order, counters);
    const gateway = new ChatGateway(
      router as never,
      { api: { getSession: vi.fn() } } as never,
      brain as never,
      {} as never,
      {} as never,
      {} as never,
    );
    const client = {
      id: 'browser-fail-1',
      data: { user: { id: 'user-a' } },
      emit: vi.fn(),
    };

    await gateway.handleMessage(
      client as never,
      {
        conversationId: 'conversation-fail-1',
        content: 'will fail persistence',
      } as never,
    );

    // A failed user-message persistence aborts the turn with no accept-then-lose: no session:info,
    // no ack, the lease never dispatches or prompts, and the just-prepared lease is disposed exactly
    // once (listener/channel cleanup) without throwing. The fixed safe error is surfaced.
    expect(client.emit).not.toHaveBeenCalledWith('session:info', expect.anything());
    expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
    expect(counters.dispatch).toBe(0);
    expect(agentService.prompt).not.toHaveBeenCalled();
    expect(counters.dispose).toBe(1);
    expect(unsub).toHaveBeenCalledTimes(1);
    expect(client.emit).toHaveBeenCalledWith(
      'error',
      expect.objectContaining({ code: 'persist_failed' }),
    );
    expect(harnessConversations.append).not.toHaveBeenCalled();
  });

  it('drops a legacy browser turn when persistence RESOLVES nullish (not only on rejection): zero info/ack/dispatch/prompt, one clean disposal (Task 5 finding 2)', async () => {
    // Companion to the rejected-promise case above. A brain adapter that resolves `undefined`/`null`
    // instead of throwing must be treated as a persistence FAILURE, never as a saved message — the
    // pre-fix code accepted a nullish resolve and dispatched a turn whose user message was never
    // durably stored. RED before finding-2: the turn acks + dispatches + prompts on a phantom persist.
    process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
    const order: string[] = [];
    const counters = { dispatch: 0, dispose: 0 };
    const unsub = vi.fn();
    const session = {
      provider: 'configured-provider',
      modelId: 'configured-model',
      piSession: {
        thinkingLevel: 'medium',
        getAvailableThinkingLevels: (): string[] => ['medium'],
      },
    };
    const agentService = {
      getSession: vi.fn().mockReturnValue(undefined),
      createSession: vi.fn().mockResolvedValue(session),
      recordMessage: vi.fn(),
      onEvent: vi.fn().mockReturnValue(unsub),
      addChannel: vi.fn(),
      removeChannel: vi.fn(),
      prompt: vi.fn().mockResolvedValue(undefined),
    };
    const harnessConversations = { append: vi.fn() };
    const brain = {
      conversations: {
        findById: vi.fn().mockResolvedValue({ id: 'conversation-nullish-1' }),
        findMessages: vi.fn().mockResolvedValue([]),
        create: vi.fn().mockResolvedValue(undefined),
        update: vi.fn().mockResolvedValue(undefined),
        // Resolves nullish rather than throwing: the fix must still fail the turn closed.
        addMessage: vi.fn().mockResolvedValue(undefined),
      },
    };
    const router = readyRouter('legacy', agentService, harnessConversations);
    instrumentLease(router, order, counters);
    const gateway = new ChatGateway(
      router as never,
      { api: { getSession: vi.fn() } } as never,
      brain as never,
      {} as never,
      {} as never,
      {} as never,
    );
    const client = {
      id: 'browser-nullish-1',
      data: { user: { id: 'user-a' } },
      emit: vi.fn(),
    };

    await gateway.handleMessage(
      client as never,
      {
        conversationId: 'conversation-nullish-1',
        content: 'persist resolves undefined',
      } as never,
    );

    expect(client.emit).not.toHaveBeenCalledWith('session:info', expect.anything());
    expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
    expect(counters.dispatch).toBe(0);
    expect(agentService.prompt).not.toHaveBeenCalled();
    expect(counters.dispose).toBe(1);
    expect(unsub).toHaveBeenCalledTimes(1);
    expect(client.emit).toHaveBeenCalledWith(
      'error',
      expect.objectContaining({ code: 'persist_failed' }),
    );
    expect(harnessConversations.append).not.toHaveBeenCalled();
  });

  it("fails a legacy browser send closed when the supplied conversationId is not the sender's: conversation_unavailable, zero persist/mint/dispatch/prompt (Task 5 finding 1)", async () => {
    // A browser socket that supplies a conversationId it does not own must be refused at admission,
    // BEFORE any runtime effect. Pre-fix, an unresolved/foreign id fell through to session mint +
    // dispatch, letting a caller attach to (or resurrect) a conversation outside their scope.
    process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
    const order: string[] = [];
    const counters = { dispatch: 0, dispose: 0 };
    const agentService = {
      getSession: vi.fn().mockReturnValue(undefined),
      createSession: vi.fn().mockResolvedValue(undefined),
      recordMessage: vi.fn(),
      onEvent: vi.fn().mockReturnValue((): void => undefined),
      addChannel: vi.fn(),
      removeChannel: vi.fn(),
      prompt: vi.fn().mockResolvedValue(undefined),
    };
    const harnessConversations = { append: vi.fn() };
    const addMessage = vi.fn().mockResolvedValue({ id: 'must-not-persist' });
    const brain = {
      conversations: {
        // Scoped lookup: the sender does not own this id, so admission resolves undefined.
        findById: vi.fn().mockResolvedValue(undefined),
        findMessages: vi.fn().mockResolvedValue([]),
        create: vi.fn().mockResolvedValue(undefined),
        update: vi.fn().mockResolvedValue(undefined),
        addMessage,
      },
    };
    const router = readyRouter('legacy', agentService, harnessConversations);
    instrumentLease(router, order, counters);
    const gateway = new ChatGateway(
      router as never,
      { api: { getSession: vi.fn() } } as never,
      brain as never,
      {} as never,
      {} as never,
      {} as never,
    );
    const client = {
      id: 'browser-foreign-1',
      data: { user: { id: 'user-a' } },
      emit: vi.fn(),
    };

    await gateway.handleMessage(
      client as never,
      {
        conversationId: 'conversation-foreign-1',
        content: 'let me into a conversation I do not own',
      } as never,
    );

    // Admission consults the scoped durable record with the SENDER's id, then fails closed: no
    // persist, no session mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal.
    expect(brain.conversations.findById).toHaveBeenCalledWith('conversation-foreign-1', 'user-a');
    expect(addMessage).not.toHaveBeenCalled();
    expect(brain.conversations.create).not.toHaveBeenCalled();
    expect(agentService.createSession).not.toHaveBeenCalled();
    expect(agentService.onEvent).not.toHaveBeenCalled();
    expect(agentService.addChannel).not.toHaveBeenCalled();
    expect(agentService.prompt).not.toHaveBeenCalled();
    expect(counters.dispatch).toBe(0);
    expect(counters.dispose).toBe(0);
    expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
    expect(client.emit).toHaveBeenCalledWith(
      'error',
      expect.objectContaining({ code: 'conversation_unavailable' }),
    );
    expect(harnessConversations.append).not.toHaveBeenCalled();
  });

  it('admits a legacy browser send with NO conversationId by minting a scoped durable record first, then dispatches once (Task 5 finding 1)', async () => {
    // The distinct server-minted-new path: a client that omits conversationId is a brand-new
    // conversation. Admission must CREATE the durable record (scoped to the sender) before the turn
    // persists/dispatches, and must not consult the ownership lookup (there is nothing to authorize
    // yet). This guards the fix from over-reaching and breaking new-conversation creation.
    process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
    const order: string[] = [];
    const counters = { dispatch: 0, dispose: 0 };
    const session = {
      provider: 'configured-provider',
      modelId: 'configured-model',
      piSession: {
        thinkingLevel: 'medium',
        getAvailableThinkingLevels: (): string[] => ['medium'],
      },
    };
    const agentService = {
      getSession: vi.fn().mockReturnValue(undefined),
      createSession: vi.fn().mockResolvedValue(session),
      recordMessage: vi.fn(),
      onEvent: vi.fn().mockReturnValue((): void => undefined),
      addChannel: vi.fn(),
      removeChannel: vi.fn(),
      prompt: vi.fn().mockResolvedValue(undefined),
    };
    const harnessConversations = { append: vi.fn() };
    // The durable store honours the mint: it returns the exact record asked for (same id, same
    // scoped owner). Admission requires that non-null, correctly-identified record before any effect.
    const create = vi.fn((data: { id: string; userId: string }) =>
      Promise.resolve({ id: data.id, userId: data.userId }),
    );
    const brain = {
      conversations: {
        findById: vi.fn().mockResolvedValue(undefined),
        findMessages: vi.fn().mockResolvedValue([]),
        create,
        update: vi.fn().mockResolvedValue(undefined),
        addMessage: vi.fn().mockResolvedValue({ id: 'persisted-new' }),
      },
    };
    const router = readyRouter('legacy', agentService, harnessConversations);
    instrumentLease(router, order, counters);
    const gateway = new ChatGateway(
      router as never,
      { api: { getSession: vi.fn() } } as never,
      brain as never,
      {} as never,
      {} as never,
      {} as never,
    );
    const client = {
      id: 'browser-new-1',
      data: { user: { id: 'user-a' } },
      emit: vi.fn(),
    };

    await gateway.handleMessage(
      client as never,
      {
        content: 'first message with no conversationId',
      } as never,
    );

    // The durable record is minted for the sender (create with the same server id the turn persists
    // under), and the admitted new-conversation turn dispatches exactly once.
    expect(create).toHaveBeenCalledWith(
      expect.objectContaining({ id: expect.any(String), userId: 'user-a' }),
    );
    expect(counters.dispatch).toBe(1);
    expect(agentService.prompt).toHaveBeenCalledTimes(1);
    expect(harnessConversations.append).not.toHaveBeenCalled();
  });

  it('fails a legacy browser send with NO conversationId closed when the durable mint resolves nullish, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => {
    // Minting is not fire-and-forget: if create resolves nullish (the durable write silently
    // produced no record), admission must fail closed BEFORE any runtime effect rather than dispatch
    // against a conversation that was never persisted.
    process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
    const order: string[] = [];
    const counters = { dispatch: 0, dispose: 0 };
    const session = {
      provider: 'configured-provider',
      modelId: 'configured-model',
      piSession: {
        thinkingLevel: 'medium',
        getAvailableThinkingLevels: (): string[] => ['medium'],
      },
    };
    const agentService = {
      getSession: vi.fn().mockReturnValue(undefined),
      createSession: vi.fn().mockResolvedValue(session),
      recordMessage: vi.fn(),
      onEvent: vi.fn().mockReturnValue((): void => undefined),
      addChannel: vi.fn(),
      removeChannel: vi.fn(),
      prompt: vi.fn().mockResolvedValue(undefined),
    };
    const harnessConversations = { append: vi.fn() };
    const create = vi.fn().mockResolvedValue(undefined);
    const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' });
    const brain = {
      conversations: {
        findById: vi.fn().mockResolvedValue(undefined),
        findMessages: vi.fn().mockResolvedValue([]),
        create,
        update: vi.fn().mockResolvedValue(undefined),
        addMessage,
      },
    };
    const router = readyRouter('legacy', agentService, harnessConversations);
    instrumentLease(router, order, counters);
    const gateway = new ChatGateway(
      router as never,
      { api: { getSession: vi.fn() } } as never,
      brain as never,
      {} as never,
      {} as never,
      {} as never,
    );
    const client = {
      id: 'browser-new-nullish-1',
      data: { user: { id: 'user-a' } },
      emit: vi.fn(),
    };

    await gateway.handleMessage(
      client as never,
      {
        content: 'first message, mint resolves nullish',
      } as never,
    );

    // The mint was attempted for the sender, then admission failed closed: no persist, no session
    // mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal.
    expect(create).toHaveBeenCalledWith(
      expect.objectContaining({ id: expect.any(String), userId: 'user-a' }),
    );
    expect(addMessage).not.toHaveBeenCalled();
    expect(agentService.createSession).not.toHaveBeenCalled();
    expect(agentService.prompt).not.toHaveBeenCalled();
    expect(counters.dispatch).toBe(0);
    expect(counters.dispose).toBe(0);
    expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
    expect(client.emit).toHaveBeenCalledWith(
      'error',
      expect.objectContaining({ code: 'conversation_unavailable' }),
    );
    expect(harnessConversations.append).not.toHaveBeenCalled();
  });

  it('fails a legacy browser send with NO conversationId closed when the durable mint throws, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => {
    // A create that rejects (durable store error) is a persistence failure, not a reason to proceed:
    // the exception is caught at the admission seam and collapses to the same fail-closed refusal.
    process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
    const order: string[] = [];
    const counters = { dispatch: 0, dispose: 0 };
    const session = {
      provider: 'configured-provider',
      modelId: 'configured-model',
      piSession: {
        thinkingLevel: 'medium',
        getAvailableThinkingLevels: (): string[] => ['medium'],
      },
    };
    const agentService = {
      getSession: vi.fn().mockReturnValue(undefined),
      createSession: vi.fn().mockResolvedValue(session),
      recordMessage: vi.fn(),
      onEvent: vi.fn().mockReturnValue((): void => undefined),
      addChannel: vi.fn(),
      removeChannel: vi.fn(),
      prompt: vi.fn().mockResolvedValue(undefined),
    };
    const harnessConversations = { append: vi.fn() };
    const create = vi.fn().mockRejectedValue(new Error('durable store unavailable'));
    const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' });
    const brain = {
      conversations: {
        findById: vi.fn().mockResolvedValue(undefined),
        findMessages: vi.fn().mockResolvedValue([]),
        create,
        update: vi.fn().mockResolvedValue(undefined),
        addMessage,
      },
    };
    const router = readyRouter('legacy', agentService, harnessConversations);
    instrumentLease(router, order, counters);
    const gateway = new ChatGateway(
      router as never,
      { api: { getSession: vi.fn() } } as never,
      brain as never,
      {} as never,
      {} as never,
      {} as never,
    );
    const client = {
      id: 'browser-new-throw-1',
      data: { user: { id: 'user-a' } },
      emit: vi.fn(),
    };

    await gateway.handleMessage(
      client as never,
      {
        content: 'first message, mint throws',
      } as never,
    );

    // The mint was attempted for the sender, then admission failed closed on the thrown error: no
    // persist, no session mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal.
    expect(create).toHaveBeenCalledWith(
      expect.objectContaining({ id: expect.any(String), userId: 'user-a' }),
    );
    expect(addMessage).not.toHaveBeenCalled();
    expect(agentService.createSession).not.toHaveBeenCalled();
    expect(agentService.prompt).not.toHaveBeenCalled();
    expect(counters.dispatch).toBe(0);
    expect(counters.dispose).toBe(0);
    expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
    expect(client.emit).toHaveBeenCalledWith(
      'error',
      expect.objectContaining({ code: 'conversation_unavailable' }),
    );
    expect(harnessConversations.append).not.toHaveBeenCalled();
  });
});

describe('Chat DTO validation', () => {
  it('rejects unsupported message roles', () => {
    const dto = Object.assign(new SendMessageDto(), {
      content: 'hello',
      role: 'moderator',
    });

    const errors = validateSync(dto);

    expect(errors.length).toBeGreaterThan(0);
  });

  it('rejects oversized conversation message content above 10000 characters', () => {
    const dto = Object.assign(new SendMessageDto(), {
      content: 'x'.repeat(10_001),
      role: 'user',
    });

    const errors = validateSync(dto);

    expect(errors.length).toBeGreaterThan(0);
  });

  it('rejects oversized chat content above 10000 characters', () => {
    const dto = Object.assign(new ChatRequestDto(), {
      content: 'x'.repeat(10_001),
    });

    const errors = validateSync(dto);

    expect(errors.length).toBeGreaterThan(0);
  });
});
