import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { EventEmitter } from 'node:events';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { env } from '../config/env.js';
import {
  buildHealthCheckUrls,
  buildRestartFailureMessage,
  buildServerCommandApiRestartUrl,
  listServerCommands,
  resolveDockerSocketPath,
  restartServerCommand,
  readWorkServerDeploymentState,
} from './server-command-service.js';

test('buildRestartFailureMessage includes exit info and stderr output', () => {
  const message = buildRestartFailureMessage(
    'TEST',
    Object.assign(new Error('Command failed'), {
      code: 1,
      stderr: 'no such service: app',
      stdout: '',
    }),
  );

  assert.match(message, /TEST 재기동에 실패했습니다\./);
  assert.match(message, /exit:1/);
  assert.match(message, /no such service: app/);
});

test('listServerCommands uses app as the default test restart service', async () => {
  const commands = await listServerCommands();
  const testCommand = commands.find((item) => item.key === 'test');

  assert.ok(testCommand);
  assert.equal(testCommand.serviceName, 'app');
});

test('listServerCommands exposes prod restart command', async () => {
  const commands = await listServerCommands();
  const prodCommand = commands.find((item) => item.key === 'prod');

  assert.ok(prodCommand);
  assert.equal(prodCommand.serviceName, 'prod-app');
  assert.match(prodCommand.commandScript, /\/etc\/commands\/server-command\/restart-prod\.sh$/);
});

test('listServerCommands resolves restart script from main project when project root fallback is needed', async () => {
  const commands = await listServerCommands();
  const testCommand = commands.find((item) => item.key === 'test');

  assert.ok(testCommand);
  assert.match(testCommand.commandScript, /\/etc\/commands\/server-command\/restart-test\.sh$/);
  assert.notEqual(testCommand.commandScript, '/etc/commands/server-command/restart-test.sh');
});

test('test, release and prod restart scripts fall back to Docker socket when docker CLI is unavailable', () => {
  const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
  const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8');
  const relScript = fs.readFileSync(new URL('restart-rel.sh', commandsRoot), 'utf8');
  const prodScript = fs.readFileSync(new URL('restart-prod.sh', commandsRoot), 'utf8');
  const workServerScript = fs.readFileSync(new URL('restart-work-server.sh', commandsRoot), 'utf8');
  const socketRestartScript = fs.readFileSync(new URL('restart-via-docker-socket.mjs', commandsRoot), 'utf8');

  assert.match(testScript, /command -v docker >/);
  assert.match(testScript, /git fetch "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
  assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
  assert.match(testScript, /SERVER_COMMAND_TEST_GIT_REMOTE="\$\{SERVER_COMMAND_TEST_GIT_REMOTE:-origin\}"/);
  assert.match(testScript, /SERVER_COMMAND_TEST_GIT_BRANCH="\$\{SERVER_COMMAND_TEST_GIT_BRANCH:-main\}"/);
  assert.match(
    testScript,
    /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --no-deps --force-recreate "\$SERVER_COMMAND_SERVICE"/,
  );
  assert.match(testScript, /TEST_BUILD_STAMP_FILE="\$\{TEST_BUILD_STAMP_FILE:-\$MAIN_PROJECT_ROOT\/\.server-command-test-app-built-at\}"/);
  assert.match(testScript, /date -Iseconds > "\$TEST_BUILD_STAMP_FILE"/);
  assert.match(testScript, /restart-via-docker-socket\.mjs/);
  assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/);
  assert.match(relScript, /command -v docker >/);
  assert.match(
    relScript,
    /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/,
  );
  assert.match(relScript, /restart-via-docker-socket\.mjs/);
  assert.match(relScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release\}"/);
  assert.match(prodScript, /command -v docker >/);
  assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/);
  assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_REMOTE="\$\{SERVER_COMMAND_PROD_GIT_REMOTE:-origin\}"/);
  assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_BRANCH="\$\{SERVER_COMMAND_PROD_GIT_BRANCH:-main\}"/);
  assert.match(
    prodScript,
    /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/,
  );
  assert.match(prodScript, /restart-via-docker-socket\.mjs/);
  assert.match(prodScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod\}"/);
  assert.match(
    workServerScript,
    /ACTIVE_SLOT_FILE="\$\{WORK_SERVER_ACTIVE_SLOT_FILE:-\$REPO_ROOT\/etc\/servers\/work-server\/\.docker\/runtime\/active-slot\}"/,
  );
  assert.match(
    workServerScript,
    /docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$TARGET_SERVICE"/,
  );
  assert.match(workServerScript, /RUNTIME_ENDPOINT="\$\{WORK_SERVER_RUNTIME_ENDPOINT:-http:\/\/127\.0\.0\.1:3100\/api\/runtime\}"/);
  assert.match(
    workServerScript,
    /RECOVERY_ENDPOINT="\$\{WORK_SERVER_RECOVERY_ENDPOINT:-http:\/\/127\.0\.0\.1:3100\/api\/runtime\/recover-interrupted-chat\}"/,
  );
  assert.match(workServerScript, /set_container_draining "\$PREVIOUS_CONTAINER" true/);
  assert.match(workServerScript, /wait_for_previous_slot_drain "\$PREVIOUS_CONTAINER"/);
  assert.match(
    workServerScript,
    /docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$PREVIOUS_SERVICE"/,
  );
  assert.match(workServerScript, /recover_interrupted_chat_requests "\$TARGET_CONTAINER"/);
  assert.match(workServerScript, /docker exec "\$PROXY_CONTAINER" nginx -s reload/);
  assert.match(workServerScript, /wait_for_container_runtime_ready "\$TARGET_CONTAINER" "\$TARGET_SLOT"/);
  assert.match(workServerScript, /wait_for_proxy_slot_health "\$TARGET_SLOT"/);
  assert.match(workServerScript, /Promise.all\(\[fetch\(process.argv\[1\]\), fetch\(process.argv\[2\]\)\]\)/);
  assert.match(workServerScript, /payload\?\.slot !== expectedSlot/);
  assert.match(workServerScript, /runtime readiness check failed/);
  assert.match(workServerScript, /STABLE_SUCCESS_COUNT=\$\(\(STABLE_SUCCESS_COUNT \+ 1\)\)/);
  assert.match(workServerScript, /work-server zero-downtime switch completed/);
  assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
});

test('prod restart script pulls the configured remote main branch before restart', () => {
  const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
  const prodScript = fs.readFileSync(new URL('restart-prod.sh', commandsRoot), 'utf8');

  assert.match(prodScript, /CURRENT_BRANCH=\$\(git rev-parse --abbrev-ref HEAD 2>\/dev\/null \|\| true\)/);
  assert.match(prodScript, /git -c "credential\.helper=store --file=\$CREDENTIAL_STORE" pull --ff-only/);
  assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/);
});

test('test restart script pulls the configured remote main branch before restart', () => {
  const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
  const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8');

  assert.match(testScript, /git fetch "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
  assert.match(testScript, /git switch "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
  assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
});

test('test deploy script commits the main worktree before pushing and restarting the preview server', () => {
  const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
  const deployScript = fs.readFileSync(new URL('deploy-test.sh', commandsRoot), 'utf8');

  assert.match(deployScript, /TEST_BUILD_COMMAND="\$\{TEST_BUILD_COMMAND:-npm run build:test-app\}"/);
  assert.match(deployScript, /TEST_DEPLOY_COMMIT_MESSAGE="\$\{TEST_DEPLOY_COMMIT_MESSAGE:-chore: test deploy snapshot\}"/);
  assert.match(deployScript, /echo "::step::commit-main-worktree"/);
  assert.match(deployScript, /git add -A -- \./);
  assert.match(deployScript, /git commit -m "\$TEST_DEPLOY_COMMIT_MESSAGE"/);
  assert.match(deployScript, /echo "::step::push-origin-main"/);
  assert.match(deployScript, /git push "\$TEST_DEPLOY_GIT_REMOTE" "\$TEST_DEPLOY_GIT_BRANCH"/);
  assert.match(deployScript, /TEST_SERVER_RESTART_SCRIPT="\$\{TEST_SERVER_RESTART_SCRIPT:-\$SCRIPT_DIR\/restart-test\.sh\}"/);
  assert.doesNotMatch(deployScript, /restart-work-server\.sh/);
  assert.match(deployScript, /REPO_ROOT="\$REPO_ROOT" sh "\$TEST_SERVER_RESTART_SCRIPT"/);
});

test('work-server package dev script does not use watch mode and rebuilds before start', async () => {
  const packageJsonPath = new URL('../../package.json', import.meta.url);
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
    scripts?: Record<string, string>;
  };

  assert.equal(packageJson.scripts?.dev, 'npm run build && npm run start');
  assert.doesNotMatch(String(packageJson.scripts?.dev ?? ''), /\bwatch\b/i);
});

test('buildServerCommandApiRestartUrl replaces key placeholders on configured command api endpoint', () => {
  assert.equal(
    buildServerCommandApiRestartUrl('http://127.0.0.1:3200/', '/commands/{key}/restart', 'work-server'),
    'http://127.0.0.1:3200/commands/work-server/restart',
  );
});

test('buildServerCommandApiRestartUrl avoids duplicating an existing base path prefix', () => {
  assert.equal(
    buildServerCommandApiRestartUrl(
      'http://127.0.0.1:3211/api',
      '/api/server-commands/{key}/actions/restart',
      'test',
    ),
    'http://127.0.0.1:3211/api/server-commands/test/actions/restart',
  );
});

test('buildHealthCheckUrls adds localhost fallbacks for command-runner', () => {
  assert.deepEqual(buildHealthCheckUrls('command-runner', 'http://host.docker.internal:3211/health'), [
    'http://host.docker.internal:3211/health',
    'http://127.0.0.1:3211/health',
    'http://localhost:3211/health',
  ]);
  assert.deepEqual(buildHealthCheckUrls('work-server', 'http://host.docker.internal:3100/health'), [
    'http://host.docker.internal:3100/health',
  ]);
});

test('resolveDockerSocketPath prefers explicit socket path and falls back to unix DOCKER_HOST', () => {
  assert.equal(
    resolveDockerSocketPath({
      SERVER_COMMAND_DOCKER_SOCKET: '/custom/docker.sock',
      DOCKER_HOST: 'unix:///run/user/1000/docker.sock',
    }),
    '/custom/docker.sock',
  );

  assert.equal(
    resolveDockerSocketPath({
      DOCKER_HOST: 'unix:///run/user/1000/docker.sock',
    }),
    '/run/user/1000/docker.sock',
  );
});

test('restartServerCommand delegates to configured command api when base url is provided', async (t) => {
  const originalBaseUrl = env.SERVER_COMMAND_API_BASE_URL;
  const originalAccessToken = env.SERVER_COMMAND_API_ACCESS_TOKEN;
  const originalPathTemplate = env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE;

  env.SERVER_COMMAND_API_BASE_URL = 'http://127.0.0.1:3200/api';
  env.SERVER_COMMAND_API_ACCESS_TOKEN = 'local-command-token';
  env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE = '/commands/{key}/restart';

  const fetchMock = t.mock.method(globalThis, 'fetch', async (input: string | URL | Request, init?: RequestInit) => {
    assert.equal(String(input), 'http://127.0.0.1:3200/api/commands/test/restart');
    assert.equal(init?.method, 'POST');
    const headers = new Headers(init?.headers);
    assert.equal(headers.get('X-Access-Token'), 'local-command-token');

    return new Response(
      JSON.stringify({
        restartState: 'accepted',
        item: {
          key: 'test',
          label: 'TEST',
          composeStatus: 'restarting',
        },
        commandOutput: 'restart accepted',
      }),
      {
        status: 200,
        headers: {
          'content-type': 'application/json',
        },
      },
    );
  });

  try {
    const result = await restartServerCommand('test');

    assert.equal(fetchMock.mock.callCount(), 1);
    assert.equal(result.restartState, 'accepted');
    assert.equal(result.commandOutput, 'restart accepted');
    assert.equal(result.server.key, 'test');
    assert.equal(result.server.composeStatus, 'restarting');
  } finally {
    env.SERVER_COMMAND_API_BASE_URL = originalBaseUrl;
    env.SERVER_COMMAND_API_ACCESS_TOKEN = originalAccessToken;
    env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE = originalPathTemplate;
  }
});

test('restartServerCommand surfaces deferred restart script failures for work-server', async (t) => {
  const originalBaseUrl = env.SERVER_COMMAND_API_BASE_URL;
  env.SERVER_COMMAND_API_BASE_URL = '';
  const childProcessModule = (await import('node:child_process')) as { spawn: (...args: unknown[]) => unknown };

  const spawnMock = t.mock.method(
    childProcessModule,
    'spawn',
    (...spawnArgs: unknown[]) => {
      const args = Array.isArray(spawnArgs[1]) ? (spawnArgs[1] as string[]) : [];
      const shellCommand = String(args[1] ?? '');
      const logPath = shellCommand.match(/>"([^"]+\.log)"/)?.[1] ?? '';
      const statusPath = shellCommand.match(/>"([^"]+\.status)"/)?.[1] ?? '';

      queueMicrotask(() => {
        void writeFile(statusPath, '1', 'utf8');
        void writeFile(logPath, 'docker compose failed', 'utf8');
      });

      const child = new EventEmitter() as EventEmitter & { unref(): void };
      child.unref = () => undefined;
      queueMicrotask(() => {
        child.emit('spawn');
      });
      return child;
    },
  );

  try {
    await assert.rejects(() => restartServerCommand('work-server'), /WORK-SERVER 재기동에 실패했습니다\./);
    assert.equal(spawnMock.mock.callCount(), 1);
  } finally {
    env.SERVER_COMMAND_API_BASE_URL = originalBaseUrl;
  }
});

test('listServerCommands marks command-runner online when localhost fallback responds', async (t) => {
  const fetchMock = t.mock.method(globalThis, 'fetch', async (input: string | URL | Request) => {
    const url = String(input);

    if (url === 'http://host.docker.internal:3211/health') {
      throw new Error('fetch failed');
    }

    if (url === 'http://127.0.0.1:3211/health') {
      return new Response(JSON.stringify({ ok: true, service: 'server-command-runner' }), {
        status: 200,
        headers: {
          'content-type': 'application/json',
        },
      });
    }

    return new Response('ok', { status: 200 });
  });

  const commands = await listServerCommands();
  const runnerCommand = commands.find((item) => item.key === 'command-runner');

  assert.ok(runnerCommand);
  assert.equal(fetchMock.mock.calls.some((call) => String(call.arguments[0]) === 'http://host.docker.internal:3211/health'), true);
  assert.equal(fetchMock.mock.calls.some((call) => String(call.arguments[0]) === 'http://127.0.0.1:3211/health'), true);
  assert.equal(runnerCommand.availability, 'online');
  assert.equal(runnerCommand.httpStatus, 200);
  assert.match(String(runnerCommand.errorMessage ?? ''), /fallback health check succeeded via http:\/\/127\.0\.0\.1:3211\/health/);
});

test('listServerCommands ignores public codex chat resources when checking app source updates', async () => {
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-app-source-scan-'));
  const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
  const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
  const buildTargetPath = '/tmp/ai-code-test-app-dist/index.html';
  const previousBuildContents = fs.existsSync(buildTargetPath) ? await fs.promises.readFile(buildTargetPath) : null;
  const previousBuildStat = fs.existsSync(buildTargetPath) ? await fs.promises.stat(buildTargetPath) : null;

  try {
    const staleDate = new Date('2026-04-19T00:00:00.000Z');
    await mkdir(path.join(tempRoot, 'src'), { recursive: true });
    await mkdir(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource'), { recursive: true });
    await writeFile(path.join(tempRoot, 'src', 'main.tsx'), 'export const app = true;\n', 'utf8');
    await writeFile(path.join(tempRoot, 'src', 'main.test.ts'), 'export const testOnly = true;\n', 'utf8');
    await writeFile(path.join(tempRoot, 'index.html'), '<!doctype html>\n', 'utf8');
    await writeFile(path.join(tempRoot, 'package.json'), '{"name":"tmp"}\n', 'utf8');
    await writeFile(path.join(tempRoot, 'tsconfig.json'), '{}\n', 'utf8');
    await writeFile(path.join(tempRoot, 'tsconfig.app.json'), '{}\n', 'utf8');
    await writeFile(path.join(tempRoot, 'vite.config.ts'), 'export default {};\n', 'utf8');
    await writeFile(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), 'resource only\n', 'utf8');
    await Promise.all([
      fs.promises.utimes(path.join(tempRoot, 'src', 'main.tsx'), staleDate, staleDate),
      fs.promises.utimes(path.join(tempRoot, 'src', 'main.test.ts'), staleDate, staleDate),
      fs.promises.utimes(path.join(tempRoot, 'index.html'), staleDate, staleDate),
      fs.promises.utimes(path.join(tempRoot, 'package.json'), staleDate, staleDate),
      fs.promises.utimes(path.join(tempRoot, 'tsconfig.json'), staleDate, staleDate),
      fs.promises.utimes(path.join(tempRoot, 'tsconfig.app.json'), staleDate, staleDate),
      fs.promises.utimes(path.join(tempRoot, 'vite.config.ts'), staleDate, staleDate),
    ]);

    await mkdir(path.dirname(buildTargetPath), { recursive: true });
    await writeFile(buildTargetPath, '<!doctype html>\n', 'utf8');
    const buildDate = new Date('2026-04-20T00:00:00.000Z');
    await fs.promises.utimes(buildTargetPath, buildDate, buildDate);

    const resourceDate = new Date('2026-04-28T00:00:00.000Z');
    await fs.promises.utimes(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), resourceDate, resourceDate);
    const excludedTestDate = new Date('2026-05-01T00:00:00.000Z');
    await fs.promises.utimes(path.join(tempRoot, 'src', 'main.test.ts'), excludedTestDate, excludedTestDate);

    env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
    env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;

    const commands = await listServerCommands();
    const testCommand = commands.find((item) => item.key === 'test');

    assert.ok(testCommand);
    assert.equal(testCommand.buildRequired, false);
    assert.notEqual(testCommand.latestSourceChangePath, 'public/.codex_chat/session/resource/note.txt');
    assert.notEqual(testCommand.latestSourceChangePath, 'src/main.test.ts');
  } finally {
    env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
    env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;

    if (previousBuildContents) {
      await writeFile(buildTargetPath, previousBuildContents, 'utf8');
      if (previousBuildStat) {
        await fs.promises.utimes(buildTargetPath, previousBuildStat.atime, previousBuildStat.mtime);
      }
    } else {
      await rm(buildTargetPath, { force: true });
    }

    await rm(tempRoot, { recursive: true, force: true });
  }
});


test('listServerCommands ignores work-server test-only source changes when computing buildRequired', async () => {
  const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
  const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-source-scan-'));

  try {
    const workServerRoot = path.join(tempRoot, 'etc', 'servers', 'work-server');
    await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
    await writeFile(path.join(tempRoot, 'package.json'), '{"name":"main-project-temp"}\n', 'utf8');
    await mkdir(path.join(workServerRoot, 'src', 'services'), { recursive: true });
    await mkdir(path.join(workServerRoot, 'scripts'), { recursive: true });
    await writeFile(path.join(workServerRoot, 'src', 'services', 'service.ts'), 'export const live = true;\n', 'utf8');
    await writeFile(path.join(workServerRoot, 'src', 'services', 'service.test.ts'), 'export const testOnly = true;\n', 'utf8');
    await writeFile(path.join(workServerRoot, 'package.json'), '{"name":"work-server"}\n', 'utf8');
    await writeFile(path.join(workServerRoot, 'tsconfig.json'), '{}\n', 'utf8');
    await mkdir(path.join(workServerRoot, 'dist'), { recursive: true });
    await writeFile(
      path.join(workServerRoot, 'dist', 'build-info.json'),
      JSON.stringify({ version: '0.1.0', buildId: '0.1.0@2026-05-25T07:58:59.046Z', builtAt: '2026-05-25T07:58:59.046Z' }),
      'utf8',
    );

    const staleDate = new Date('2026-05-20T00:00:00.000Z');
    const excludedTestDate = new Date('2026-06-01T00:00:00.000Z');
    await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.ts'), staleDate, staleDate);
    await fs.promises.utimes(path.join(workServerRoot, 'package.json'), staleDate, staleDate);
    await fs.promises.utimes(path.join(workServerRoot, 'tsconfig.json'), staleDate, staleDate);
    await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.test.ts'), excludedTestDate, excludedTestDate);

    env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
    env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;

    const commands = await listServerCommands();
    const workServerCommand = commands.find((item) => item.key === 'work-server');

    assert.ok(workServerCommand);
    assert.equal(workServerCommand.buildRequired, false);
    assert.notEqual(workServerCommand.latestSourceChangePath, 'src/services/service.test.ts');
  } finally {
    env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
    env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
    await rm(tempRoot, { recursive: true, force: true });
  }
});

test('listServerCommands keeps work-server updateAvailable false when only a standby rebuild is newer', async () => {
  const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
  const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-standby-build-'));

  try {
    const workServerRoot = path.join(tempRoot, 'etc', 'servers', 'work-server');
    await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
    await writeFile(path.join(tempRoot, 'package.json'), '{"name":"main-project-temp"}\n', 'utf8');
    await mkdir(path.join(workServerRoot, 'src', 'services'), { recursive: true });
    await mkdir(path.join(workServerRoot, 'scripts'), { recursive: true });
    await mkdir(path.join(workServerRoot, 'dist'), { recursive: true });
    await writeFile(path.join(workServerRoot, 'src', 'services', 'service.ts'), 'export const live = true;\n', 'utf8');
    await writeFile(path.join(workServerRoot, 'package.json'), '{"name":"work-server"}\n', 'utf8');
    await writeFile(path.join(workServerRoot, 'tsconfig.json'), '{}\n', 'utf8');
    await writeFile(
      path.join(workServerRoot, 'dist', 'build-info.json'),
      JSON.stringify({ version: '0.1.0', buildId: '0.1.0@2026-05-26T16:10:05.960Z', builtAt: '2026-05-26T16:10:05.960Z' }),
      'utf8',
    );

    const sourceDate = new Date('2026-05-26T16:06:46.162Z');
    await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.ts'), sourceDate, sourceDate);
    await fs.promises.utimes(path.join(workServerRoot, 'package.json'), sourceDate, sourceDate);
    await fs.promises.utimes(path.join(workServerRoot, 'tsconfig.json'), sourceDate, sourceDate);

    env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
    env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;

    const commands = await listServerCommands();
    const workServerCommand = commands.find((item) => item.key === 'work-server');

    assert.ok(workServerCommand);
    assert.equal(workServerCommand.buildRequired, false);
    assert.equal(workServerCommand.updateAvailable, false);
  } finally {
    env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
    env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
    await rm(tempRoot, { recursive: true, force: true });
  }
});


test('readWorkServerDeploymentState transitions stale running deployment to failed when the lock is gone', async () => {
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-deployment-'));
  const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
  const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;

  try {
    env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
    env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
    await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
    await writeFile(path.join(tempRoot, 'package.json'), '{"name":"temp-root"}\n', 'utf8');

    const runtimeDir = path.join(tempRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime');
    await mkdir(runtimeDir, { recursive: true });
    await writeFile(
      path.join(runtimeDir, 'deployment-state.json'),
      JSON.stringify({
        status: 'running',
        phase: 'drain-previous-slot',
        summary: '이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다.',
        startedAt: '2026-05-28T01:45:37.000Z',
        updatedAt: '2026-05-28T01:54:20.000Z',
        activeSlot: 'blue',
        targetSlot: 'blue',
        previousSlot: 'green',
        steps: [
          { key: 'build-target-slot', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:37.000Z' },
          { key: 'verify-target-health', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:53.000Z' },
          { key: 'switch-proxy', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:57.000Z' },
          { key: 'drain-previous-slot', status: 'running', detail: 'active 2 · queued 0', updatedAt: '2026-05-28T01:54:20.000Z' },
          { key: 'rebuild-previous-slot', status: 'pending', detail: null, updatedAt: null },
          { key: 'recover-interrupted-chat', status: 'pending', detail: null, updatedAt: null },
        ],
      }) + '\n',
      'utf8',
    );

    const snapshot = await readWorkServerDeploymentState();

    assert.ok(snapshot);
    assert.equal(snapshot.status, 'failed');
    assert.equal(snapshot.phase, 'failed');
    assert.equal(snapshot.steps.find((item) => item.key === 'drain-previous-slot')?.status, 'failed');
    assert.match(String(snapshot.lastError ?? ''), /lock 파일이 없어서/);
  } finally {
    env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
    env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
    await rm(tempRoot, { recursive: true, force: true });
  }
});

test('readWorkServerDeploymentState keeps running deployment when the lock is active', async () => {
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-deployment-lock-'));
  const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
  const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;

  try {
    env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
    env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
    await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
    await writeFile(path.join(tempRoot, 'package.json'), '{"name":"temp-root"}\n', 'utf8');

    const runtimeDir = path.join(tempRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime');
    await mkdir(runtimeDir, { recursive: true });
    await writeFile(
      path.join(runtimeDir, 'deployment-state.json'),
      JSON.stringify({
        status: 'running',
        phase: 'drain-previous-slot',
        summary: '이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다.',
        startedAt: '2026-05-28T01:45:37.000Z',
        updatedAt: '2026-05-28T01:54:20.000Z',
        steps: [
          { key: 'build-target-slot', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:37.000Z' },
          { key: 'verify-target-health', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:53.000Z' },
          { key: 'switch-proxy', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:57.000Z' },
          { key: 'drain-previous-slot', status: 'running', detail: 'active 2 · queued 0', updatedAt: '2026-05-28T01:54:20.000Z' },
          { key: 'rebuild-previous-slot', status: 'pending', detail: null, updatedAt: null },
          { key: 'recover-interrupted-chat', status: 'pending', detail: null, updatedAt: null },
        ],
      }) + '\n',
      'utf8',
    );
    await writeFile(
      path.join(runtimeDir, 'restart-in-progress.json'),
      JSON.stringify({ startedAt: new Date().toISOString(), key: 'work-server', pid: 1234 }) + '\n',
      'utf8',
    );

    const snapshot = await readWorkServerDeploymentState();

    assert.ok(snapshot);
    assert.equal(snapshot.status, 'running');
    assert.equal(snapshot.phase, 'drain-previous-slot');
    assert.equal(snapshot.steps.find((item) => item.key === 'drain-previous-slot')?.status, 'running');
  } finally {
    env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
    env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
    await rm(tempRoot, { recursive: true, force: true });
  }
});
