jest.mock('uuid', () => ({
  v4: jest.fn(() => 'session-id'),
}));

import { NetaClawSessionService } from '../src/modules/netaclaw/gateway/session.js';
import { NetaClawGateway } from '../src/modules/netaclaw/gateway/server.js';
import { FileSessionTreeProvider } from '../src/modules/netaclaw/session-tree/file_provider.js';
import { MySqlSessionTreeProvider } from '../src/modules/netaclaw/session-tree/mysql_provider.js';

describe('NetaClawSessionService.deleteAllSessions', () => {
  let service: NetaClawSessionService;

  beforeEach(() => {
    service = new NetaClawSessionService();
  });

  it('deletes all subagent sessions, messages, and sessions without calling delete({}) when userId is omitted', async () => {
    const sessionRows = [{ sessionId: 's1' }, { sessionId: 's2' }];
    const sessionDeleteBuilder = {
      delete: jest.fn().mockReturnThis(),
      from: jest.fn().mockReturnThis(),
      execute: jest.fn().mockResolvedValue(undefined),
    };

    service.sessionRepo = {
      find: jest.fn().mockResolvedValue(sessionRows),
      delete: jest.fn().mockRejectedValue(new Error('delete({}) should not be called')),
      createQueryBuilder: jest.fn().mockReturnValue(sessionDeleteBuilder),
    } as any;
    service.messageRepo = {
      delete: jest.fn().mockResolvedValue(undefined),
    } as any;
    service.subagentSessionRepo = {
      delete: jest.fn().mockResolvedValue(undefined),
    } as any;
    service.agentSessionRepo = {
      find: jest.fn().mockResolvedValue([]),
      delete: jest.fn().mockResolvedValue(undefined),
      createQueryBuilder: jest.fn().mockReturnValue({
        delete: jest.fn().mockReturnThis(),
        from: jest.fn().mockReturnThis(),
        execute: jest.fn().mockResolvedValue(undefined),
      }),
    } as any;
    service.agentSessionEntryRepo = {
      delete: jest.fn().mockResolvedValue(undefined),
    } as any;

    await service.deleteAllSessions();

    expect(service.sessionRepo.find).toHaveBeenCalledWith({ where: {} });
    expect(service.subagentSessionRepo.delete).toHaveBeenCalledTimes(2);
    expect(service.subagentSessionRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' });
    expect(service.subagentSessionRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' });
    expect(service.messageRepo.delete).toHaveBeenCalledTimes(2);
    expect(service.messageRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' });
    expect(service.messageRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' });
    expect(service.agentSessionEntryRepo.delete).toHaveBeenCalledTimes(2);
    expect(service.agentSessionEntryRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' });
    expect(service.agentSessionEntryRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' });
    expect(service.agentSessionRepo.delete).toHaveBeenCalledTimes(2);
    expect(service.agentSessionRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' });
    expect(service.agentSessionRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' });
    expect(service.agentSessionRepo.find).toHaveBeenCalledWith();
    expect(service.agentSessionRepo.createQueryBuilder).toHaveBeenCalledWith();
    expect(service.sessionRepo.delete).not.toHaveBeenCalled();
    expect(service.sessionRepo.createQueryBuilder).toHaveBeenCalledWith();
    expect(sessionDeleteBuilder.delete).toHaveBeenCalled();
    expect(sessionDeleteBuilder.from).toHaveBeenCalled();
    expect(sessionDeleteBuilder.execute).toHaveBeenCalled();
  });

  it('keeps user-scoped deletes on repository criteria when userId is provided', async () => {
    const sessionRows = [{ sessionId: 's1' }];

    service.sessionRepo = {
      find: jest.fn().mockResolvedValue(sessionRows),
      delete: jest.fn().mockResolvedValue(undefined),
      createQueryBuilder: jest.fn(),
    } as any;
    service.messageRepo = {
      delete: jest.fn().mockResolvedValue(undefined),
    } as any;
    service.subagentSessionRepo = {
      delete: jest.fn().mockResolvedValue(undefined),
    } as any;
    service.agentSessionRepo = {
      find: jest.fn().mockResolvedValue([]),
      delete: jest.fn().mockResolvedValue(undefined),
      createQueryBuilder: jest.fn(),
    } as any;
    service.agentSessionEntryRepo = {
      delete: jest.fn().mockResolvedValue(undefined),
    } as any;

    await service.deleteAllSessions('user-1');

    expect(service.sessionRepo.find).toHaveBeenCalledWith({ where: { userId: 'user-1' } });
    expect(service.subagentSessionRepo.delete).toHaveBeenCalledWith({ sessionId: 's1' });
    expect(service.messageRepo.delete).toHaveBeenCalledWith({ sessionId: 's1' });
    expect(service.agentSessionEntryRepo.delete).toHaveBeenCalledWith({ sessionId: 's1' });
    expect(service.agentSessionRepo.find).toHaveBeenCalledWith({ where: { userId: 'user-1' } });
    expect(service.agentSessionRepo.delete).toHaveBeenCalledWith({ userId: 'user-1' });
    expect(service.sessionRepo.delete).toHaveBeenCalledWith({ userId: 'user-1' });
    expect(service.sessionRepo.createQueryBuilder).not.toHaveBeenCalled();
    expect(service.agentSessionRepo.createQueryBuilder).not.toHaveBeenCalled();
  });
});

describe('NetaClawSessionService.deleteSession', () => {
  let service: NetaClawSessionService;

  beforeEach(() => {
    service = new NetaClawSessionService();
    service.agentSessionEntryRepo = {
      delete: jest.fn().mockResolvedValue(undefined),
    } as any;
    service.agentSessionRepo = {
      findOneBy: jest.fn().mockResolvedValue(null),
      delete: jest.fn().mockResolvedValue(undefined),
    } as any;
    service.subagentSessionRepo = {
      delete: jest.fn().mockResolvedValue(undefined),
    } as any;
    service.messageRepo = {
      delete: jest.fn().mockResolvedValue(undefined),
    } as any;
    service.sessionRepo = {
      delete: jest.fn().mockResolvedValue(undefined),
    } as any;
  });

  it('deletes the active session-tree provider storage for the selected agent before legacy rows', async () => {
    const provider = {
      deleteSession: jest.fn().mockResolvedValue(undefined),
    };
    jest.spyOn(service, 'getSessionTreeProviderForAgent').mockResolvedValue(provider as any);

    await service.deleteSession('session-tree-1', 7);

    expect(service.getSessionTreeProviderForAgent).toHaveBeenCalledWith(7);
    expect(provider.deleteSession).toHaveBeenCalledWith('session-tree-1');
    expect(service.agentSessionEntryRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' });
    expect(service.agentSessionRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' });
    expect(service.subagentSessionRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' });
    expect(service.messageRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' });
    expect(service.sessionRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' });
  });

  it('resolves the owning agent from mysql session-tree rows before selecting the provider', async () => {
    const provider = {
      deleteSession: jest.fn().mockResolvedValue(undefined),
    };
    (service.agentSessionRepo.findOneBy as jest.Mock).mockResolvedValue({
      sessionId: 'mysql-session-1',
      agentId: '8',
    });
    jest.spyOn(service, 'getSessionTreeProviderForAgent').mockResolvedValue(provider as any);

    await service.deleteSession('mysql-session-1', 7);

    expect(service.agentSessionRepo.findOneBy).toHaveBeenCalledWith({ sessionId: 'mysql-session-1' });
    expect(service.getSessionTreeProviderForAgent).toHaveBeenCalledWith(8);
    expect(provider.deleteSession).toHaveBeenCalledWith('mysql-session-1');
  });
});

describe('NetaClawSessionService history ordering', () => {
  let service: NetaClawSessionService;

  beforeEach(() => {
    service = new NetaClawSessionService();
  });

  it('orders compacted history by createTime asc and id asc', async () => {
    service.messageRepo = {
      find: jest.fn().mockResolvedValue([]),
    } as any;

    await service.loadHistory('session-1');

    const [args] = (service.messageRepo.find as jest.Mock).mock.calls[0];
    expect(args.where.sessionId).toBe('session-1');
    expect(args.where.compactedAt?._type).toBe('isNull');
    expect(args.order).toEqual({ createTime: 'ASC', id: 'ASC' });
  });

  it('orders history-with-id and full message views by createTime asc and id asc', async () => {
    service.messageRepo = {
      find: jest.fn().mockResolvedValue([]),
    } as any;

    await service.loadHistoryWithId('session-1');
    await service.getMessages('session-1', 'full');

    const [firstCallArgs] = (service.messageRepo.find as jest.Mock).mock.calls[0];
    const [secondCallArgs] = (service.messageRepo.find as jest.Mock).mock.calls[1];

    expect(firstCallArgs.where.sessionId).toBe('session-1');
    expect(firstCallArgs.where.compactedAt?._type).toBe('isNull');
    expect(firstCallArgs.order).toEqual({ createTime: 'ASC', id: 'ASC' });

    expect(secondCallArgs).toEqual({
      where: { sessionId: 'session-1' },
      order: { createTime: 'ASC', id: 'ASC' },
    });
  });
});

describe('NetaClawSessionService session-tree provider resolution', () => {
  let service: NetaClawSessionService;

  beforeEach(() => {
    service = new NetaClawSessionService();
    service.netaclawConfig = {
      dataDir: 'C:/neta-data',
      defaultModel: 'anthropic:claude-sonnet-4-20250514',
      apiKeys: {},
      skillsDir: './skills',
      maxToolRounds: 20,
      session: {
        backend: 'file',
      },
    } as any;
    service.agentRepo = {
      findOneBy: jest.fn(),
    } as any;
    service.agentSessionRepo = {
      find: jest.fn(),
      findOne: jest.fn(),
      findOneBy: jest.fn(),
      save: jest.fn(),
      delete: jest.fn(),
    } as any;
    service.agentSessionEntryRepo = {
      find: jest.fn(),
      findOne: jest.fn(),
      findOneBy: jest.fn(),
      save: jest.fn(),
      delete: jest.fn(),
    } as any;
  });

  it('uses the global file backend by default when the agent has no session config', async () => {
    (service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({
      id: 7,
      name: 'default',
      config: {},
    });

    const provider = await service.getSessionTreeProviderForAgent(7);

    expect(provider).toBeInstanceOf(FileSessionTreeProvider);
    expect((provider as FileSessionTreeProvider).getSessionFilePath('session-1')).toBe(
      'C:\\neta-data\\sessions\\c2Vzc2lvbi0x.jsonl'
    );
  });

  it('defaults to file backend when both agent config and global session config are absent', async () => {
    service.netaclawConfig = {
      dataDir: 'C:/neta-data',
      defaultModel: 'anthropic:claude-sonnet-4-20250514',
      apiKeys: {},
      skillsDir: './skills',
      maxToolRounds: 20,
    } as any;
    (service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({
      id: 11,
      name: 'default-no-session-config',
      config: {},
    });

    const provider = await service.getSessionTreeProviderForAgent(11);

    expect(provider).toBeInstanceOf(FileSessionTreeProvider);
    expect((provider as FileSessionTreeProvider).getSessionFilePath('session-3')).toBe(
      'C:\\neta-data\\sessions\\c2Vzc2lvbi0z.jsonl'
    );
  });

  it('lets the agent override the backend to mysql', async () => {
    (service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({
      id: 8,
      name: 'mysql-agent',
      config: {
        session: {
          backend: 'mysql',
        },
      },
    });

    const provider = await service.getSessionTreeProviderForAgent(8);

    expect(provider).toBeInstanceOf(MySqlSessionTreeProvider);
  });

  it('lets the agent override the file root dir', async () => {
    (service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({
      id: 9,
      name: 'file-agent',
      config: {
        session: {
          backend: 'file',
          file: {
            rootDir: 'D:/agent-sessions',
          },
        },
      },
    });

    const provider = await service.getSessionTreeProviderForAgent(9);

    expect(provider).toBeInstanceOf(FileSessionTreeProvider);
    expect((provider as FileSessionTreeProvider).getSessionFilePath('session-2')).toBe(
      'D:\\agent-sessions\\c2Vzc2lvbi0y.jsonl'
    );
  });

  it('throws when mysql session backend is selected but mysql repositories are unavailable', async () => {
    service.agentSessionRepo = undefined as any;
    (service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({
      id: 10,
      name: 'broken-mysql-agent',
      config: {
        session: {
          backend: 'mysql',
        },
      },
    });

    await expect(service.getSessionTreeProviderForAgent(10)).rejects.toThrow(
      'MySqlSessionTreeProvider requires both sessionRepo and entryRepo'
    );
  });
});

describe('NetaClawGateway compaction queue draining', () => {
  it('keeps queued chats pending during inline auto-compaction when drainPending is false', async () => {
    const gateway = new NetaClawGateway() as any;
    gateway.logger = {
      info: jest.fn(),
      warn: jest.fn(),
      error: jest.fn(),
    };
    gateway.ctx = { emit: jest.fn() };
    gateway.sessionService = {
      loadHistoryWithId: jest.fn().mockResolvedValue([{ id: 1, role: 'user', content: 'hi' }]),
      updateSessionMetadata: jest.fn().mockResolvedValue(undefined),
    };
    gateway.compactionService = {
      compact: jest.fn().mockResolvedValue({
        success: true,
        compactedCount: 1,
        keepRecentCount: 1,
        summaryMessageId: 99,
        summaryContent: 'summary',
        tokensBefore: 10,
        tokensAfter: 5,
      }),
    };
    gateway.sessionState.set('session-1', {
      pendingChat: [{ content: 'queued', agentName: 'default', agentId: 7 }],
    });

    const handleChatSpy = jest.spyOn(gateway, 'handleChat').mockResolvedValue(undefined);

    await gateway.handleCompact(
      'session-1',
      'auto',
      { compactionThreshold: 70, modelConfig: {} },
      false
    );

    expect(handleChatSpy).not.toHaveBeenCalled();
    expect(gateway.sessionState.get('session-1').pendingChat).toEqual([
      { content: 'queued', agentName: 'default', agentId: 7 },
    ]);
  });
});

describe('NetaClawGateway session-tree routing', () => {
  it('routes websocket agent chat through executeSessionTreeChat instead of the legacy executeChat path', async () => {
    const gateway = new NetaClawGateway() as any;
    gateway.logger = {
      info: jest.fn(),
      warn: jest.fn(),
      error: jest.fn(),
    };
    gateway.ctx = { emit: jest.fn() };
    gateway.sessionService = {
      getOrCreateSession: jest.fn().mockResolvedValue('session-tree-1'),
      updateSessionMetadata: jest.fn().mockResolvedValue(undefined),
    };
    gateway.agentService = {
      info: jest.fn().mockResolvedValue({
        id: 7,
        name: 'default',
        systemPrompt: 'You are the runtime.',
        skills: [],
        config: {},
        modelConfig: {},
        subagentConfig: {},
      }),
    };
    gateway.skillLoader = {} as any;
    gateway.channelService = {
      resolveForAgent: jest.fn(),
    };
    gateway.toolResolver = {
      resolve: jest.fn().mockResolvedValue({
        tools: [],
        toolNames: [],
        toolPromptHints: {},
        builtinToolNames: [],
        disabledReasons: [],
      }),
    };
    gateway.chatOrchestrator = {
      executeSessionTreeChat: jest.fn().mockImplementation(async ({ runner }: any) => {
        await runner({
          sessionId: 'session-tree-1',
          history: [],
          assistantEntryId: 'entry_assistant_1',
          subagents: {
            sessionId: 'session-tree-1',
            parentMessageId: 0,
            batches: [],
            onBatchStart: jest.fn(),
            onUpdate: jest.fn(),
            onDone: jest.fn(),
          },
          userMessage: 'hello tree',
        });

        return {
          sessionId: 'session-tree-1',
          finalContent: 'final answer',
          content: 'final answer',
          usage: { inputTokens: 12, outputTokens: 5 },
          toolCallCount: 0,
          assistantEntryId: 'entry_assistant_1',
          subagentBatches: [],
        };
      }),
      executeChat: jest.fn(),
    };
    gateway.subagentService = {} as any;
    gateway.compactionService = {} as any;

    await gateway.handleChat('session-tree-1', 'hello tree', 'default', 7);

    expect(gateway.chatOrchestrator.executeSessionTreeChat).toHaveBeenCalledWith(
      expect.objectContaining({
        sessionId: 'session-tree-1',
        requestedAgentName: 'default',
        message: 'hello tree',
        agentId: 7,
        runner: expect.any(Function),
      })
    );
    expect(gateway.chatOrchestrator.executeChat).not.toHaveBeenCalled();
  });

  it('does not run legacy linear-history compaction checks before session-tree websocket chat', async () => {
    const gateway = new NetaClawGateway() as any;
    gateway.logger = {
      info: jest.fn(),
      warn: jest.fn(),
      error: jest.fn(),
    };
    gateway.ctx = { emit: jest.fn() };
    gateway.sessionService = {
      getOrCreateSession: jest.fn().mockResolvedValue('session-tree-1'),
      updateSessionMetadata: jest.fn().mockResolvedValue(undefined),
      loadHistory: jest.fn().mockRejectedValue(new Error('legacy loadHistory should not be called')),
    };
    gateway.agentService = {
      info: jest.fn().mockResolvedValue({
        id: 7,
        name: 'default',
        systemPrompt: 'You are the runtime.',
        skills: [],
        config: {},
        modelConfig: {},
        subagentConfig: {},
        compactionThreshold: 1,
      }),
    };
    gateway.skillLoader = {} as any;
    gateway.channelService = {
      resolveForAgent: jest.fn(),
    };
    gateway.toolResolver = {
      resolve: jest.fn().mockResolvedValue({
        tools: [],
        toolNames: [],
        toolPromptHints: {},
        builtinToolNames: [],
        disabledReasons: [],
      }),
    };
    gateway.chatOrchestrator = {
      executeSessionTreeChat: jest.fn().mockResolvedValue({
        sessionId: 'session-tree-1',
        finalContent: 'final answer',
        content: 'final answer',
        usage: undefined,
        toolCallCount: 0,
        assistantEntryId: 'entry_assistant_1',
        subagentBatches: [],
      }),
      executeChat: jest.fn(),
    };
    gateway.subagentService = {} as any;
    gateway.compactionService = {} as any;
    const handleCompactSpy = jest.spyOn(gateway, 'handleCompact');

    await gateway.handleChat('session-tree-1', 'hello tree', 'default', 7);

    expect(gateway.agentService.info).toHaveBeenCalledTimes(1);
    expect(gateway.sessionService.loadHistory).not.toHaveBeenCalled();
    expect(handleCompactSpy).not.toHaveBeenCalled();
    expect(gateway.chatOrchestrator.executeSessionTreeChat).toHaveBeenCalled();
  });
});
