import fs from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { makeShortTempDir } from './fixtures/test-helpers.js';

const launchDaemonDetached = vi.hoisted(() => vi.fn());

vi.mock('../src/daemon/launch.js', () => ({
  launchDaemonDetached,
}));

const { DaemonClient, resolveDaemonPaths } = await import('../src/daemon/client.js');

interface MockDaemonOptions {
  readonly configPath: string;
  readonly socketPath: string;
  readonly metadataPath: string;
}

const servers: net.Server[] = [];
let previousDaemonDir: string | undefined;

describe('DaemonClient lifecycle reconciliation', () => {
  beforeEach(() => {
    previousDaemonDir = process.env.MCPORTER_DAEMON_DIR;
    launchDaemonDetached.mockReset();
    launchDaemonDetached.mockImplementation((options: MockDaemonOptions) => {
      void startMockDaemon(options, process.pid);
    });
  });

  afterEach(async () => {
    await Promise.all(servers.splice(0).map((server) => closeServer(server)));
    if (previousDaemonDir === undefined) {
      delete process.env.MCPORTER_DAEMON_DIR;
    } else {
      process.env.MCPORTER_DAEMON_DIR = previousDaemonDir;
    }
  });

  it('serializes concurrent daemon starts with a filesystem lock', async () => {
    const tmpDir = await makeShortTempDir('daemon-lock');
    process.env.MCPORTER_DAEMON_DIR = tmpDir;
    const configPath = path.join(tmpDir, 'config.json');
    await fs.writeFile(
      configPath,
      JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }),
      'utf8'
    );

    const firstClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
    const secondClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });

    await Promise.all([firstClient.listTools({ server: 'warm' }), secondClient.listTools({ server: 'warm' })]);

    expect(launchDaemonDetached).toHaveBeenCalledTimes(1);
  });

  it('rejects socket responders that do not match metadata pid', async () => {
    const tmpDir = await makeShortTempDir('daemon-pid');
    process.env.MCPORTER_DAEMON_DIR = tmpDir;
    const configPath = path.join(tmpDir, 'config.json');
    await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }), 'utf8');
    const { socketPath, metadataPath } = resolveDaemonPaths(configPath);
    await fs.mkdir(path.dirname(metadataPath), { recursive: true });
    await fs.writeFile(
      metadataPath,
      JSON.stringify({
        pid: process.pid,
        socketPath,
        configPath,
        configLayers: [{ path: configPath, mtimeMs: (await fs.stat(configPath)).mtimeMs }],
        startedAt: Date.now(),
      }),
      'utf8'
    );
    await startStatusServer(socketPath, process.pid + 10_000, configPath);

    const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });

    await expect(client.status()).resolves.toBeNull();
  });

  it('forces a new daemon after a request transport failure even when status still responds', async () => {
    const tmpDir = await makeShortTempDir('daemon-restart');
    process.env.MCPORTER_DAEMON_DIR = tmpDir;
    const configPath = path.join(tmpDir, 'config.json');
    await fs.writeFile(
      configPath,
      JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }),
      'utf8'
    );
    const paths = resolveDaemonPaths(configPath);
    await startMockDaemon({ ...paths, configPath }, process.pid, { failCallTool: true });

    const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
    const result = await client.callTool({ server: 'warm', tool: 'list' });

    expect(result).toEqual({ ok: true });
    expect(launchDaemonDetached).toHaveBeenCalledTimes(1);
  });

  it('deduplicates concurrent stale-config restarts after the first replacement wins', async () => {
    const tmpDir = await makeShortTempDir('daemon-stale-lock');
    process.env.MCPORTER_DAEMON_DIR = tmpDir;
    const configPath = path.join(tmpDir, 'config.json');
    await fs.writeFile(
      configPath,
      JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }),
      'utf8'
    );
    const stat = await fs.stat(configPath);
    const deadPid = findNonRunningPid();
    const paths = resolveDaemonPaths(configPath);
    await fs.mkdir(path.dirname(paths.metadataPath), { recursive: true });
    await fs.writeFile(
      paths.metadataPath,
      JSON.stringify({
        pid: deadPid,
        socketPath: paths.socketPath,
        configPath,
        configLayers: [{ path: configPath, mtimeMs: stat.mtimeMs - 1000 }],
        startedAt: Date.now() - 10_000,
      }),
      'utf8'
    );

    const firstClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
    const secondClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });

    await Promise.all([firstClient.listTools({ server: 'warm' }), secondClient.listTools({ server: 'warm' })]);

    expect(launchDaemonDetached).toHaveBeenCalledTimes(1);
  });
});

async function startMockDaemon(
  options: MockDaemonOptions,
  pid: number,
  behavior: { failCallTool?: boolean } = {}
): Promise<void> {
  const stat = await fs.stat(options.configPath);
  await startStatusServer(options.socketPath, pid, options.configPath, options.metadataPath, behavior);
  await fs.mkdir(path.dirname(options.metadataPath), { recursive: true });
  await fs.writeFile(
    options.metadataPath,
    JSON.stringify({
      pid,
      socketPath: options.socketPath,
      configPath: options.configPath,
      configLayers: [{ path: options.configPath, mtimeMs: stat.mtimeMs }],
      startedAt: Date.now(),
    }),
    'utf8'
  );
}

async function startStatusServer(
  socketPath: string,
  pid: number,
  configPath: string,
  metadataPath?: string,
  behavior: { failCallTool?: boolean } = {}
): Promise<void> {
  await fs.mkdir(path.dirname(socketPath), { recursive: true });
  await fs.unlink(socketPath).catch(() => {});
  const server = net.createServer((socket) => {
    let buffer = '';
    socket.setEncoding('utf8');
    socket.on('data', (chunk) => {
      buffer += chunk;
      const request = JSON.parse(buffer) as { id: string; method: string };
      if (request.method === 'callTool' && behavior.failCallTool) {
        behavior.failCallTool = false;
        socket.destroy();
        return;
      }
      if (request.method === 'stop') {
        socket.end(JSON.stringify({ id: request.id, ok: true, result: true }), () => {
          server.close(() => {});
          if (metadataPath) {
            void fs.unlink(metadataPath).catch(() => {});
          }
        });
        return;
      }
      const result =
        request.method === 'status'
          ? {
              pid,
              startedAt: Date.now(),
              configPath,
              socketPath,
              servers: [],
            }
          : request.method === 'callTool'
            ? { ok: true }
            : { tools: [] };
      socket.end(JSON.stringify({ id: request.id, ok: true, result }));
    });
  });
  await new Promise<void>((resolve, reject) => {
    server.once('error', reject);
    server.listen(socketPath, () => {
      server.off('error', reject);
      servers.push(server);
      resolve();
    });
  });
}

async function closeServer(server: net.Server): Promise<void> {
  await new Promise<void>((resolve) => {
    server.close(() => resolve()).on('error', () => resolve());
  });
}

function findNonRunningPid(): number {
  for (let pid = process.pid + 100_000; pid < process.pid + 101_000; pid += 1) {
    try {
      process.kill(pid, 0);
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
        return pid;
      }
    }
  }
  throw new Error('Unable to find a non-running pid for daemon tests.');
}
