import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import { PassThrough } from 'stream';
import { spawn } from 'child_process';

// Sentinel markers must match container-runner.ts
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';

// Mock config
vi.mock('./config.js', () => ({
  CONTAINER_IMAGE: 'nanoclaw-agent:latest',
  CONTAINER_MAX_OUTPUT_SIZE: 10485760,
  CONTAINER_TIMEOUT: 1800000, // 30min
  DATA_DIR: '/tmp/nanoclaw-test-data',
  GROUPS_DIR: '/tmp/nanoclaw-test-groups',
  IDLE_TIMEOUT: 1800000, // 30min
  TIMEZONE: 'America/Los_Angeles',
}));

// Mock logger
vi.mock('./logger.js', () => ({
  logger: {
    debug: vi.fn(),
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
  },
}));

// Mock fs
vi.mock('fs', async () => {
  const actual = await vi.importActual<typeof import('fs')>('fs');
  return {
    ...actual,
    default: {
      ...actual,
      existsSync: vi.fn(() => false),
      mkdirSync: vi.fn(),
      writeFileSync: vi.fn(),
      readFileSync: vi.fn(() => ''),
      rmSync: vi.fn(),
      readdirSync: vi.fn(() => []),
      statSync: vi.fn(() => ({ isDirectory: () => false })),
      lstatSync: vi.fn(() => ({ isSymbolicLink: () => false })),
      copyFileSync: vi.fn(),
      symlinkSync: vi.fn(),
    },
  };
});

// Mock mount-security
vi.mock('./mount-security.js', () => ({
  validateAdditionalMounts: vi.fn(() => []),
}));

// Create a controllable fake ChildProcess
function createFakeProcess() {
  const proc = new EventEmitter() as EventEmitter & {
    stdin: PassThrough;
    stdout: PassThrough;
    stderr: PassThrough;
    kill: ReturnType<typeof vi.fn>;
    pid: number;
  };
  proc.stdin = new PassThrough();
  proc.stdout = new PassThrough();
  proc.stderr = new PassThrough();
  proc.kill = vi.fn();
  proc.pid = 12345;
  return proc;
}

let fakeProc: ReturnType<typeof createFakeProcess>;

// Mock child_process.spawn
vi.mock('child_process', async () => {
  const actual =
    await vi.importActual<typeof import('child_process')>('child_process');
  return {
    ...actual,
    spawn: vi.fn(() => fakeProc),
    execSync: vi.fn(() => ''),
    execFileSync: vi.fn(() => ''),
    exec: vi.fn(
      (_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => {
        if (cb) cb(null);
        return new EventEmitter();
      },
    ),
  };
});

import { runContainerAgent, ContainerOutput } from './container-runner.js';
import type { RegisteredGroup } from './types.js';
import fs from 'fs';

const testGroup: RegisteredGroup = {
  name: 'Test Group',
  folder: 'test-group',
  added_at: new Date().toISOString(),
};

const testInput = {
  prompt: 'Hello',
  groupFolder: 'test-group',
  chatJid: 'test@g.us',
  isMain: false,
};

function emitOutputMarker(
  proc: ReturnType<typeof createFakeProcess>,
  output: ContainerOutput,
) {
  const json = JSON.stringify(output);
  proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`);
}

describe('container-runner timeout behavior', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    fakeProc = createFakeProcess();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('timeout after output resolves as success', async () => {
    const onOutput = vi.fn(async () => {});
    const resultPromise = runContainerAgent(
      testGroup,
      testInput,
      () => {},
      onOutput,
    );

    // Emit output with a result
    emitOutputMarker(fakeProc, {
      status: 'success',
      result: 'Here is my response',
      newSessionId: 'session-123',
    });

    // Let output processing settle
    await vi.advanceTimersByTimeAsync(10);

    // Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms)
    await vi.advanceTimersByTimeAsync(1830000);

    // Emit close event (as if container was stopped by the timeout)
    fakeProc.emit('close', 137);

    // Let the promise resolve
    await vi.advanceTimersByTimeAsync(10);

    const result = await resultPromise;
    expect(result.status).toBe('success');
    expect(result.newSessionId).toBe('session-123');
    expect(onOutput).toHaveBeenCalledWith(
      expect.objectContaining({ result: 'Here is my response' }),
    );
  });

  it('timeout with no output resolves as error', async () => {
    const onOutput = vi.fn(async () => {});
    const resultPromise = runContainerAgent(
      testGroup,
      testInput,
      () => {},
      onOutput,
    );

    // No output emitted — fire the hard timeout
    await vi.advanceTimersByTimeAsync(1830000);

    // Emit close event
    fakeProc.emit('close', 137);

    await vi.advanceTimersByTimeAsync(10);

    const result = await resultPromise;
    expect(result.status).toBe('error');
    expect(result.error).toContain('timed out');
    expect(onOutput).not.toHaveBeenCalled();
  });

  it('normal exit after output resolves as success', async () => {
    const onOutput = vi.fn(async () => {});
    const resultPromise = runContainerAgent(
      testGroup,
      testInput,
      () => {},
      onOutput,
    );

    // Emit output
    emitOutputMarker(fakeProc, {
      status: 'success',
      result: 'Done',
      newSessionId: 'session-456',
    });

    await vi.advanceTimersByTimeAsync(10);

    // Normal exit (no timeout)
    fakeProc.emit('close', 0);

    await vi.advanceTimersByTimeAsync(10);

    const result = await resultPromise;
    expect(result.status).toBe('success');
    expect(result.newSessionId).toBe('session-456');
  });

  it('emits typing lifecycle signals from agent-runner stderr logs', async () => {
    const onOutput = vi.fn(async () => {});
    const resultPromise = runContainerAgent(
      testGroup,
      testInput,
      () => {},
      onOutput,
    );

    fakeProc.stderr.push('[agent-runner] Received input for group: test-group\n');
    fakeProc.stderr.push('[agent-runner] Query ended, waiting for next IPC message...\n');

    emitOutputMarker(fakeProc, {
      status: 'success',
      result: null,
      newSessionId: 'session-typing',
    });

    await vi.advanceTimersByTimeAsync(10);
    fakeProc.emit('close', 0);
    await vi.advanceTimersByTimeAsync(10);

    await resultPromise;

    expect(onOutput).toHaveBeenCalledWith(
      expect.objectContaining({ typingIndicator: 'start' }),
    );
    expect(onOutput).toHaveBeenCalledWith(
      expect.objectContaining({ typingIndicator: 'stop' }),
    );
  });

  it('passes tenant slice as docker cgroup parent when tenant uid resolves from passwd', async () => {
    const childProcess = await import('child_process');
    vi.mocked(childProcess.execFileSync).mockImplementation((file, args) => {
      if (file === 'getent' && Array.isArray(args) && args[0] === 'passwd' && args[1] === 'john') {
        return 'john:x:2010:100::/home/john:/bin/bash\n' as never;
      }
      throw new Error('unexpected lookup');
    });

    const resultPromise = runContainerAgent(
      testGroup,
      testInput,
      () => {},
      undefined,
      {
        id: 'john',
        name: 'John',
        max_concurrent: 1,
        max_memory_mb: 512,
        cpu_quota: '50%',
        cpu_weight: 100,
        max_disk_mb: 1024,
        status: 'active',
        created_at: new Date().toISOString(),
      },
    );

    emitOutputMarker(fakeProc, {
      status: 'success',
      result: 'Done',
    });

    await vi.advanceTimersByTimeAsync(10);
    fakeProc.emit('close', 0);
    await vi.advanceTimersByTimeAsync(10);
    await resultPromise;

    const spawnCalls = vi.mocked(spawn).mock.calls;
    expect(spawnCalls.length).toBeGreaterThan(0);
    const latestCall = spawnCalls.at(-1);
    expect(latestCall).toBeTruthy();
    const args = latestCall![1] as string[];
    expect(args).toContain('--cgroup-parent');
    expect(args).toContain('user-2010.slice');
    expect(args).toContain('--network');
    expect(args).toContain('nanoclaw-tenant-john');
  });

  it('passes --runtime runsc when the tenant is opted into gvisor', async () => {
    process.env.GVISOR_TENANTS = 'john';
    const childProcess = await import('child_process');
    vi.mocked(childProcess.execFileSync).mockImplementation((file, args) => {
      if (file === 'getent' && Array.isArray(args) && args[0] === 'passwd' && args[1] === 'john') {
        return 'john:x:2010:100::/home/john:/bin/bash\n' as never;
      }
      throw new Error('unexpected lookup');
    });

    const resultPromise = runContainerAgent(
      testGroup,
      testInput,
      () => {},
      undefined,
      {
        id: 'john',
        name: 'John',
        max_concurrent: 1,
        max_memory_mb: 512,
        cpu_quota: '50%',
        cpu_weight: 100,
        max_disk_mb: 1024,
        status: 'active',
        created_at: new Date().toISOString(),
      },
    );

    emitOutputMarker(fakeProc, {
      status: 'success',
      result: 'Done',
    });

    await vi.advanceTimersByTimeAsync(10);
    fakeProc.emit('close', 0);
    await vi.advanceTimersByTimeAsync(10);
    await resultPromise;

    const latestCall = vi.mocked(spawn).mock.calls.at(-1);
    expect(latestCall).toBeTruthy();
    const args = latestCall![1] as string[];
    expect(args).toContain('--runtime');
    expect(args).toContain('runsc');
    expect(args.some((arg) => arg.endsWith(':/etc/resolv.conf:ro'))).toBe(true);
    delete process.env.GVISOR_TENANTS;
  });

  it('mounts audio attachments and rewrites them to container paths', async () => {
    process.env.NANOCLAW_WHISPER_MODEL_FILE = '/host/models/whisper/ggml-base.en.bin';
    vi.mocked(fs.existsSync).mockImplementation((target: fs.PathLike) => {
      const normalized = String(target);
      return normalized === '/host/audio/note.ogg' || normalized === '/host/models/whisper/ggml-base.en.bin';
    });

    let stdinPayload = '';
    fakeProc.stdin.on('data', (chunk) => {
      stdinPayload += chunk.toString();
    });

    const resultPromise = runContainerAgent(
      testGroup,
      {
        ...testInput,
        attachments: [
          {
            kind: 'audio',
            mediaType: 'audio/ogg',
            name: 'note.ogg',
            sourcePath: '/host/audio/note.ogg',
          },
        ],
      },
      () => {},
    );

    emitOutputMarker(fakeProc, {
      status: 'success',
      result: 'Done',
    });

    await vi.advanceTimersByTimeAsync(10);
    fakeProc.emit('close', 0);
    await vi.advanceTimersByTimeAsync(10);
    await resultPromise;

    const latestCall = vi.mocked(spawn).mock.calls.at(-1);
    expect(latestCall).toBeTruthy();
    const args = latestCall![1] as string[];
    expect(args.some((arg) => arg.endsWith('/tmp/nanoclaw-test-data/ipc/test-group/attachments:/workspace/attachments:ro'))).toBe(true);
    expect(args.some((arg) => arg.endsWith('/host/models/whisper/ggml-base.en.bin:/models/whisper/ggml-base.en.bin:ro'))).toBe(true);
    expect(vi.mocked(fs.copyFileSync)).toHaveBeenCalledWith(
      '/host/audio/note.ogg',
      expect.stringContaining('/tmp/nanoclaw-test-data/ipc/test-group/attachments/'),
    );

    const containerInput = JSON.parse(stdinPayload);
    expect(containerInput.attachments).toEqual([
      expect.objectContaining({
        kind: 'audio',
        name: 'note.ogg',
        mountedPath: expect.stringMatching(/^\/workspace\/attachments\/.+-note\.ogg$/),
      }),
    ]);

    delete process.env.NANOCLAW_WHISPER_MODEL_FILE;
  });

  it('mounts the whisper model even when the initial turn has no audio attachments', async () => {
    process.env.NANOCLAW_WHISPER_MODEL_FILE = '/host/models/whisper/ggml-base.en.bin';
    vi.mocked(fs.existsSync).mockImplementation((target: fs.PathLike) => {
      return String(target) === '/host/models/whisper/ggml-base.en.bin';
    });

    const resultPromise = runContainerAgent(
      testGroup,
      {
        ...testInput,
        attachments: undefined,
      },
      () => {},
    );

    emitOutputMarker(fakeProc, {
      status: 'success',
      result: 'Done',
    });

    await vi.advanceTimersByTimeAsync(10);
    fakeProc.emit('close', 0);
    await vi.advanceTimersByTimeAsync(10);
    await resultPromise;

    const latestCall = vi.mocked(spawn).mock.calls.at(-1);
    expect(latestCall).toBeTruthy();
    const args = latestCall![1] as string[];
    expect(args.some((arg) => arg.endsWith('/host/models/whisper/ggml-base.en.bin:/models/whisper/ggml-base.en.bin:ro'))).toBe(true);

    delete process.env.NANOCLAW_WHISPER_MODEL_FILE;
  });

  it('mounts the qwen tts model directory and passes the container model path', async () => {
    process.env.NANOCLAW_QWEN_TTS_MODEL_DIR = '/host/models/qwen/qwen3-tts-12hz-0.6b-customvoice';
    vi.mocked(fs.existsSync).mockImplementation((target: fs.PathLike) => {
      return String(target) === '/host/models/qwen/qwen3-tts-12hz-0.6b-customvoice';
    });

    const resultPromise = runContainerAgent(
      testGroup,
      {
        ...testInput,
        attachments: undefined,
      },
      () => {},
    );

    emitOutputMarker(fakeProc, {
      status: 'success',
      result: 'Done',
    });

    await vi.advanceTimersByTimeAsync(10);
    fakeProc.emit('close', 0);
    await vi.advanceTimersByTimeAsync(10);
    await resultPromise;

    const latestCall = vi.mocked(spawn).mock.calls.at(-1);
    expect(latestCall).toBeTruthy();
    const args = latestCall![1] as string[];
    expect(args.some((arg) => arg.endsWith('/host/models/qwen/qwen3-tts-12hz-0.6b-customvoice:/models/qwen/qwen3-tts-12hz-0.6b-customvoice:ro'))).toBe(true);
    expect(args).toContain('NANOCLAW_QWEN_TTS_MODEL=/models/qwen/qwen3-tts-12hz-0.6b-customvoice');

    delete process.env.NANOCLAW_QWEN_TTS_MODEL_DIR;
  });
});
