import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';

vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
vi.mock('../../commands/command-executor.service.js', () => ({
  CommandExecutorService: class CommandExecutorService {},
}));
vi.mock('../routing/routing-engine.service.js', () => ({
  RoutingEngineService: class RoutingEngineService {},
}));

import { SessionsController } from '../sessions.controller.js';
import { ChatController } from '../../chat/chat.controller.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import type { AgentSession } from '../agent.service.js';
import type { SessionInfoDto } from '../session.dto.js';

const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';

function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
  return {
    id: CONVERSATION_ID,
    provider: 'test-provider',
    modelId: 'test-model',
    createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
    promptCount: 0,
    channels: [],
    durationMs: 0,
    metrics: {
      tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
      modelSwitches: 0,
      messageCount: 0,
      lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
    },
    ...overrides,
  };
}

function makeAgentSession(owner = USER_A): AgentSession {
  return {
    id: CONVERSATION_ID,
    provider: 'test-provider',
    modelId: 'test-model',
    piSession: {
      thinkingLevel: 'off',
      getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
      setThinkingLevel: vi.fn(),
      abort: vi.fn().mockResolvedValue(undefined),
      prompt: vi.fn().mockResolvedValue(undefined),
      dispose: vi.fn(),
      getSessionStats: vi.fn(),
      getContextUsage: vi.fn(),
    } as unknown as AgentSession['piSession'],
    listeners: new Set(),
    unsubscribe: vi.fn(),
    createdAt: Date.now(),
    promptCount: 0,
    channels: new Set(),
    skillPromptAdditions: [],
    sandboxDir: '/tmp',
    allowedTools: null,
    userId: owner.id,
    tenantId: owner.tenantId,
    metrics: {
      tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
      modelSwitches: 0,
      messageCount: 0,
      lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
    },
  };
}

function makeScopedAgentService() {
  const foreign = makeAgentSession(USER_A);
  return {
    listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
      scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
    ),
    getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
      scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
    ),
    destroySession: vi.fn(),
    getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
      scope?.userId === USER_B.id ? undefined : foreign,
    ),
    createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
    onEvent: vi.fn(() => vi.fn()),
    addChannel: vi.fn(),
    removeChannel: vi.fn(),
    recordMessage: vi.fn(),
    prompt: vi.fn().mockResolvedValue(undefined),
  };
}

describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
  it('requires explicit owner+tenant scope on protected session operations', () => {
    const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');

    expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
    expect(source).toContain('listSessions(scope: ActorTenantScope)');
    expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
    expect(source).toContain(
      'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
    );
    expect(source).toContain(
      'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
    );
    expect(source).toContain(
      'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
    );
    expect(source).toContain('scope: ActorTenantScope,');
    expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
    expect(source).not.toContain('scope?: ActorTenantScope');
  });
});

describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
  it('lists only sessions owned by the authenticated owner+tenant scope', () => {
    const agentService = makeScopedAgentService();
    const controller = new SessionsController(agentService as never);

    expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
    expect(agentService.listSessions).toHaveBeenCalledWith({
      userId: USER_B.id,
      tenantId: USER_B.tenantId,
    });
  });

  it('does not reveal another owner/tenant session by guessed id', () => {
    const agentService = makeScopedAgentService();
    const controller = new SessionsController(agentService as never);

    expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
    expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
      userId: USER_B.id,
      tenantId: USER_B.tenantId,
    });
  });

  it('does not terminate another owner/tenant session by guessed id', async () => {
    const agentService = makeScopedAgentService();
    const controller = new SessionsController(agentService as never);

    await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
      NotFoundException,
    );
    expect(agentService.destroySession).not.toHaveBeenCalled();
  });
});

describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
  it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
    const agentService = makeScopedAgentService();
    const controller = new ChatController(agentService as never);

    await expect(
      controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
    ).rejects.toMatchObject({ status: 404 });

    expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
      userId: USER_B.id,
      tenantId: USER_B.tenantId,
    });
    expect(agentService.prompt).not.toHaveBeenCalled();
  });
});

describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
  function makeGateway(agentService = makeScopedAgentService()) {
    const brain = {
      conversations: {
        findById: vi.fn().mockResolvedValue(undefined),
        create: vi.fn().mockResolvedValue(undefined),
        update: vi.fn().mockResolvedValue(undefined),
        findMessages: vi.fn().mockResolvedValue([]),
        addMessage: vi.fn().mockResolvedValue(undefined),
      },
    };
    const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
    const commandExecutor = { execute: vi.fn() };
    const routingEngine = {
      resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
    };
    const gateway = new ChatGateway(
      agentService as never,
      {} as never,
      brain as never,
      commandRegistry as never,
      commandExecutor as never,
      routingEngine as never,
    );
    return { gateway, agentService };
  }

  function makeSocket() {
    return {
      id: 'socket-b',
      connected: true,
      data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
      emit: vi.fn(),
      disconnect: vi.fn(),
    };
  }

  it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
    const { gateway, agentService } = makeGateway();
    const socket = makeSocket();

    await gateway.handleMessage(socket as never, {
      conversationId: CONVERSATION_ID,
      content: 'attach to foreign session',
    });

    expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
      userId: USER_B.id,
      tenantId: USER_B.tenantId,
    });
    expect(agentService.onEvent).not.toHaveBeenCalled();
    expect(agentService.addChannel).not.toHaveBeenCalled();
    expect(agentService.prompt).not.toHaveBeenCalled();
    expect(socket.emit).toHaveBeenCalledWith(
      'error',
      expect.objectContaining({ conversationId: CONVERSATION_ID }),
    );
  });

  it('does not mutate thinking level on another owner/tenant session', () => {
    const { gateway, agentService } = makeGateway();
    const socket = makeSocket();

    gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });

    expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
      userId: USER_B.id,
      tenantId: USER_B.tenantId,
    });
    expect(socket.emit).toHaveBeenCalledWith(
      'error',
      expect.objectContaining({ conversationId: CONVERSATION_ID }),
    );
  });

  it('does not terminate another owner/tenant session over WebSocket abort', async () => {
    const { gateway, agentService } = makeGateway();
    const socket = makeSocket();

    await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });

    expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
      userId: USER_B.id,
      tenantId: USER_B.tenantId,
    });
    expect(socket.emit).toHaveBeenCalledWith(
      'error',
      expect.objectContaining({ conversationId: CONVERSATION_ID }),
    );
  });
});
