/**
 * @file test-server.ts
 * @description Test server management for E2E testing
 */

import { spawn, ChildProcess } from 'child_process';
import { sha256Hex } from '@frontmcp/utils';
import { ServerStartError } from '../errors';
import { reservePort } from './port-registry';

// Environment variable to enable debug output for all test servers
const DEBUG_SERVER = process.env['DEBUG_SERVER'] === '1' || process.env['DEBUG'] === '1';

// ═══════════════════════════════════════════════════════════════════
// TYPES
// ═══════════════════════════════════════════════════════════════════

export interface TestServerOptions {
  /** Port to run the server on (default: from project range or random) */
  port?: number;
  /** E2E project name for port range allocation */
  project?: string;
  /** Command to start the server */
  command?: string;
  /** Working directory */
  cwd?: string;
  /** Environment variables */
  env?: Record<string, string>;
  /** Timeout for server startup in milliseconds (default: 30000) */
  startupTimeout?: number;
  /** Path to check for server readiness (default: /health) */
  healthCheckPath?: string;
  /** Enable debug logging */
  debug?: boolean;
}

export interface TestServerInfo {
  /** Base URL of the server */
  baseUrl: string;
  /** Port the server is running on */
  port: number;
  /** Process ID (if available) */
  pid?: number;
}

// ═══════════════════════════════════════════════════════════════════
// TEST SERVER CLASS
// ═══════════════════════════════════════════════════════════════════

/**
 * Manages test server lifecycle for E2E testing
 *
 * @example
 * ```typescript
 * // Start a server with custom command
 * const server = await TestServer.start({
 *   command: 'node dist/main.js',
 *   port: 3003,
 *   cwd: './apps/my-server',
 * });
 *
 * // Or start an Nx project
 * const server = await TestServer.startNx('demo-public', { port: 3003 });
 *
 * // Use the server
 * console.log(server.info.baseUrl); // http://localhost:3003
 *
 * // Stop when done
 * await server.stop();
 * ```
 */
export class TestServer {
  private process: ChildProcess | null = null;
  private readonly options: Required<Omit<TestServerOptions, 'project'>> & { project?: string };
  private _info: TestServerInfo;
  private logs: string[] = [];
  private portRelease: (() => Promise<void>) | null = null;

  private constructor(options: TestServerOptions, port: number, portRelease?: () => Promise<void>) {
    this.options = {
      port,
      project: options.project,
      command: options.command ?? '',
      cwd: options.cwd ?? process.cwd(),
      env: options.env ?? {},
      startupTimeout: options.startupTimeout ?? 30000,
      healthCheckPath: options.healthCheckPath ?? '/health',
      debug: options.debug ?? DEBUG_SERVER,
    };
    this.portRelease = portRelease ?? null;

    this._info = {
      baseUrl: `http://localhost:${port}`,
      port,
    };
  }

  /**
   * Start a test server with custom command
   */
  static async start(options: TestServerOptions): Promise<TestServer> {
    const project = options.project ?? 'default';
    const maxAttempts = 3;

    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      const { port, release } = await reservePort(project, options.port);
      const server = new TestServer(options, port, release);

      try {
        await server.startProcess();
        return server;
      } catch (error) {
        try {
          await server.stop();
        } catch (cleanupError) {
          if (options.debug || DEBUG_SERVER) {
            const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
            console.warn(`[TestServer] Cleanup failed after startup error: ${msg}`);
          }
        }

        const isEADDRINUSE =
          error instanceof Error &&
          (error.message.includes('EADDRINUSE') || server.getLogs().some((l) => l.includes('EADDRINUSE')));

        if (isEADDRINUSE && attempt < maxAttempts) {
          const delayMs = attempt * 500;
          if (options.debug || DEBUG_SERVER) {
            console.warn(
              `[TestServer] EADDRINUSE on port ${port}, retrying in ${delayMs}ms (attempt ${attempt}/${maxAttempts})`,
            );
          }
          await sleep(delayMs);
          continue;
        }

        throw error;
      }
    }

    // Unreachable, but TypeScript requires a return
    throw new Error(`[TestServer] Failed to start after ${maxAttempts} attempts`);
  }

  /**
   * Start an Nx project as test server
   */
  static async startNx(project: string, options: Partial<TestServerOptions> = {}): Promise<TestServer> {
    // Validate project name contains only safe characters to prevent shell injection
    if (!/^[\w-]+$/.test(project)) {
      throw new Error(
        `Invalid project name: ${project}. Must contain only alphanumeric, underscore, and hyphen characters.`,
      );
    }

    const maxAttempts = 3;

    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      const { port, release } = await reservePort(project, options.port);

      const serverOptions: TestServerOptions = {
        ...options,
        port,
        project,
        command: `npx nx serve ${project} --port ${port}`,
        cwd: options.cwd ?? process.cwd(),
      };

      const server = new TestServer(serverOptions, port, release);
      try {
        await server.startProcess();
        return server;
      } catch (error) {
        try {
          await server.stop();
        } catch (cleanupError) {
          if (options.debug || DEBUG_SERVER) {
            const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
            console.warn(`[TestServer] Cleanup failed after startup error: ${msg}`);
          }
        }

        const isEADDRINUSE =
          error instanceof Error &&
          (error.message.includes('EADDRINUSE') || server.getLogs().some((l) => l.includes('EADDRINUSE')));

        if (isEADDRINUSE && attempt < maxAttempts) {
          const delayMs = attempt * 500;
          if (options.debug || DEBUG_SERVER) {
            console.warn(
              `[TestServer] EADDRINUSE on port ${port}, retrying in ${delayMs}ms (attempt ${attempt}/${maxAttempts})`,
            );
          }
          await sleep(delayMs);
          continue;
        }

        throw error;
      }
    }

    // Unreachable, but TypeScript requires a return
    throw new Error(`[TestServer] Failed to start after ${maxAttempts} attempts`);
  }

  /**
   * Create a test server connected to an already running server
   */
  static connect(baseUrl: string): TestServer {
    const url = new URL(baseUrl);
    const port = parseInt(url.port, 10) || (url.protocol === 'https:' ? 443 : 80);

    const server = new TestServer(
      {
        command: '',
        port,
      },
      port,
    );

    server._info = {
      baseUrl: baseUrl.replace(/\/$/, ''),
      port,
    };

    return server;
  }

  /**
   * Get server information
   */
  get info(): TestServerInfo {
    return { ...this._info };
  }

  /**
   * Stop the test server
   */
  async stop(): Promise<void> {
    if (this.portRelease) {
      await this.portRelease();
      this.portRelease = null;
    }

    if (this.process) {
      this.log('Stopping server...');

      // If process already exited, just clean up the reference
      if (this.process.exitCode !== null || this.process.signalCode !== null) {
        this.log(`Server already exited (code: ${this.process.exitCode}, signal: ${this.process.signalCode})`);
        this.process = null;
        return;
      }

      // Kill entire process group to ensure all child processes exit
      const pid = this.process.pid;
      try {
        if (pid !== undefined) {
          process.kill(-pid, 'SIGTERM');
        } else {
          this.process.kill('SIGTERM');
        }
      } catch {
        this.process.kill('SIGTERM');
      }

      // Wait for process to exit
      const exitPromise = new Promise<void>((resolve) => {
        if (this.process) {
          this.process.once('exit', () => resolve());
        } else {
          resolve();
        }
      });

      // Force kill after timeout (but still wait for actual exit)
      const killTimeout = setTimeout(() => {
        if (this.process) {
          this.log('Force killing server after timeout...');
          const killPid = this.process.pid;
          try {
            if (killPid !== undefined) {
              process.kill(-killPid, 'SIGKILL');
            } else {
              this.process.kill('SIGKILL');
            }
          } catch {
            this.process.kill('SIGKILL');
          }
        }
      }, 5000);

      await exitPromise;
      clearTimeout(killTimeout);
      this.process = null;
      this.log('Server stopped');
    }
  }

  /**
   * Wait for server to be ready
   */
  async waitForReady(timeout?: number): Promise<void> {
    const timeoutMs = timeout ?? this.options.startupTimeout;
    const deadline = Date.now() + timeoutMs;
    const checkInterval = 100;

    while (Date.now() < deadline) {
      try {
        const response = await fetch(`${this._info.baseUrl}${this.options.healthCheckPath}`, {
          method: 'GET',
          signal: AbortSignal.timeout(1000),
        });

        if (response.ok || response.status === 404) {
          // 404 is okay - it means the server is running but might not have a health endpoint
          this.log('Server is ready');
          return;
        }
      } catch {
        // Server not ready yet
      }

      await sleep(checkInterval);
    }

    throw new Error(`Server did not become ready within ${timeoutMs}ms`);
  }

  /**
   * Restart the server
   */
  async restart(): Promise<void> {
    await this.stop();
    await this.startProcess();
  }

  /**
   * Get captured server logs
   */
  getLogs(): string[] {
    return [...this.logs];
  }

  /**
   * Clear captured logs
   */
  clearLogs(): void {
    this.logs = [];
  }

  // ═══════════════════════════════════════════════════════════════════
  // PRIVATE METHODS
  // ═══════════════════════════════════════════════════════════════════

  private async startProcess(): Promise<void> {
    if (!this.options.command) {
      // No command means we're connecting to an existing server
      await this.waitForReady();
      return;
    }

    this.log(`Starting server: ${this.options.command}`);

    const env = {
      ...process.env,
      ...this.options.env,
      PORT: String(this.options.port),
    };
    const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);

    // Release port reservation just before spawning so the server can bind it
    if (this.portRelease) {
      await this.portRelease();
      this.portRelease = null;
      // Brief delay to allow OS to fully release the socket
      await sleep(300);
    }

    // Use shell: true to handle complex commands with quoted arguments
    // This avoids fragile command parsing with split(' ')
    this.process = spawn(this.options.command, [], {
      cwd: this.options.cwd,
      env: runtimeEnv,
      shell: true,
      detached: true,
      stdio: ['pipe', 'pipe', 'pipe'],
    });

    // pid can be undefined if spawn fails
    if (this.process.pid !== undefined) {
      this._info.pid = this.process.pid;
    }

    // Track process exit for early failure detection
    let processExited = false;
    let exitCode: number | null = null;
    let exitError: Error | null = null;

    // Capture stdout
    this.process.stdout?.on('data', (data: Buffer) => {
      const text = data.toString();
      this.logs.push(text);
      if (this.options.debug) {
        console.log('[SERVER]', text);
      }
    });

    // Capture stderr
    this.process.stderr?.on('data', (data: Buffer) => {
      const text = data.toString();
      this.logs.push(`[ERROR] ${text}`);
      if (this.options.debug) {
        console.error('[SERVER ERROR]', text);
      }
    });

    // Handle spawn errors to prevent unhandled error events
    this.process.on('error', (err) => {
      this.logs.push(`[SPAWN ERROR] ${err.message}`);
      exitError = err;
      if (this.options.debug) {
        console.error('[SERVER SPAWN ERROR]', err);
      }
    });

    // Handle process exit - track early failures
    this.process.once('exit', (code) => {
      processExited = true;
      exitCode = code;
      this.log(`Server process exited with code ${code}`);
    });

    // Wait for server to be ready, but detect early process exit
    try {
      await this.waitForReadyWithExitDetection(() => {
        if (exitError) {
          return { exited: true, error: exitError };
        }
        if (processExited) {
          const allLogs = this.logs.join('\n');
          const errorLogs = this.logs
            .filter((l) => l.includes('[ERROR]') || l.toLowerCase().includes('error'))
            .join('\n');
          return {
            exited: true,
            error: new ServerStartError(
              `Server process exited unexpectedly with code ${exitCode}.\n\n` +
                `Command: ${this.options.command}\n` +
                `CWD: ${this.options.cwd}\n` +
                `Port: ${this.options.port}\n\n` +
                `=== Error Logs ===\n${errorLogs || 'No error logs captured'}\n\n` +
                `=== Full Logs ===\n${allLogs || 'No logs captured'}`,
            ),
          };
        }
        return { exited: false };
      });
    } catch (error) {
      // Always print logs on startup failure for debugging
      this.printLogsOnFailure('Server startup failed');
      throw error;
    }
  }

  /**
   * Print server logs on failure for debugging
   */
  private printLogsOnFailure(context: string): void {
    const allLogs = this.logs.join('\n');
    if (allLogs) {
      console.error(`\n[TestServer] ${context}`);
      console.error(`[TestServer] Command: ${this.options.command}`);
      console.error(`[TestServer] Port: ${this.options.port}`);
      console.error(`[TestServer] CWD: ${this.options.cwd}`);
      console.error(`[TestServer] === Server Logs ===\n${allLogs}`);
      console.error(`[TestServer] === End Logs ===\n`);
    }
  }

  /**
   * Wait for server to be ready, but also detect early process exit
   */
  private async waitForReadyWithExitDetection(checkExit: () => { exited: boolean; error?: Error }): Promise<void> {
    const timeoutMs = this.options.startupTimeout;
    const deadline = Date.now() + timeoutMs;
    const checkInterval = 100;
    let lastHealthCheckError: string | null = null;
    let healthCheckAttempts = 0;

    this.log(`Waiting for server to be ready (timeout: ${timeoutMs}ms)...`);

    while (Date.now() < deadline) {
      // Check if process has exited before continuing to poll
      const exitStatus = checkExit();
      if (exitStatus.exited) {
        throw exitStatus.error ?? new ServerStartError('Server process exited unexpectedly');
      }

      healthCheckAttempts++;
      try {
        const healthUrl = `${this._info.baseUrl}${this.options.healthCheckPath}`;
        const response = await fetch(healthUrl, {
          method: 'GET',
          signal: AbortSignal.timeout(1000),
        });

        if (response.ok || response.status === 404) {
          // 404 is okay - it means the server is running but might not have a health endpoint
          this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
          return;
        }
        lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
      } catch (err) {
        // Server not ready yet - capture error for debugging
        lastHealthCheckError = err instanceof Error ? err.message : String(err);
      }

      // Log progress every 5 seconds
      const elapsed = Date.now() - (deadline - timeoutMs);
      if (elapsed > 0 && elapsed % 5000 < checkInterval) {
        this.log(
          `Still waiting for server... (${Math.round(elapsed / 1000)}s elapsed, last error: ${lastHealthCheckError})`,
        );
      }

      await sleep(checkInterval);
    }

    // Final check before throwing timeout error
    const finalExitStatus = checkExit();
    if (finalExitStatus.exited) {
      throw finalExitStatus.error ?? new ServerStartError('Server process exited unexpectedly');
    }

    // Build detailed timeout error
    const allLogs = this.logs.join('\n');
    throw new ServerStartError(
      `Server did not become ready within ${timeoutMs}ms.\n\n` +
        `Command: ${this.options.command}\n` +
        `CWD: ${this.options.cwd}\n` +
        `Port: ${this.options.port}\n` +
        `Health check URL: ${this._info.baseUrl}${this.options.healthCheckPath}\n` +
        `Health check attempts: ${healthCheckAttempts}\n` +
        `Last health check error: ${lastHealthCheckError ?? 'none'}\n\n` +
        `=== Server Logs ===\n${allLogs || 'No logs captured'}\n\n` +
        `TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`,
    );
  }

  private log(message: string): void {
    if (this.options.debug) {
      console.log(`[TestServer] ${message}`);
    }
  }
}

// ═══════════════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════════════

/**
 * Sleep for specified milliseconds
 */
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Ensure spawned test servers can resolve the protocol workspace package.
 *
 * Some local installs miss the workspace symlink at `node_modules/@frontmcp/protocol`
 * even though the built package exists under `libs/protocol/dist`. `tsx` does not
 * reliably honor NODE_PATH in this path, so prefer creating the missing workspace
 * link and only fall back to NODE_PATH aliasing when that is not possible.
 */
function withWorkspaceProtocolFallback(env: NodeJS.ProcessEnv, cwd: string): NodeJS.ProcessEnv {
  if (findInstalledProtocolPackageDir(cwd)) {
    return env;
  }

  try {
    const workspacePackageDir = findWorkspaceProtocolDir(cwd);
    if (!workspacePackageDir) {
      return env;
    }

    ensureWorkspaceProtocolLink(cwd, workspacePackageDir);
    if (findInstalledProtocolPackageDir(cwd)) {
      return env;
    }

    return withProtocolNodePathAlias(env, cwd, workspacePackageDir);
  } catch (err) {
    if (DEBUG_SERVER) {
      console.error(
        `[TestServer] Workspace protocol fallback failed: ${err instanceof Error ? err.message : String(err)}`,
      );
    }
    return env;
  }
}

function ensureWorkspaceProtocolLink(cwd: string, workspacePackageDir: string): void {
  const fs = require('node:fs');
  const path = require('node:path');

  const nodeModulesDir = findWorkspaceNodeModulesDir(cwd);
  if (!nodeModulesDir) {
    return;
  }
  const scopeDir = path.join(nodeModulesDir, '@frontmcp');

  const aliasPackageDir = path.join(scopeDir, 'protocol');
  if (fs.existsSync(aliasPackageDir)) {
    return;
  }

  fs.mkdirSync(scopeDir, { recursive: true });
  try {
    fs.symlinkSync(workspacePackageDir, aliasPackageDir, process.platform === 'win32' ? 'junction' : 'dir');
  } catch (error) {
    const err = error as NodeJS.ErrnoException;
    if (err.code !== 'EEXIST') {
      throw error;
    }
  }
}

// NOTE: The helpers below use synchronous node:fs/node:path/node:os because they
// run in a synchronous code path. @frontmcp/utils only provides async FS wrappers,
// so native APIs are required here for existsSync, mkdirSync, and symlinkSync.

function withProtocolNodePathAlias(
  env: NodeJS.ProcessEnv,
  cwd: string,
  workspacePackageDir: string,
): NodeJS.ProcessEnv {
  const fs = require('node:fs');
  const os = require('node:os');
  const path = require('node:path');

  // Use a short deterministic hash to avoid Windows 260-char path limits
  // (Buffer.from(cwd).toString('hex') would produce very long directory names).
  const aliasRoot = path.join(os.tmpdir(), 'frontmcp-test-node-path', sha256Hex(cwd).slice(0, 12));
  const scopeDir = path.join(aliasRoot, '@frontmcp');
  const aliasPackageDir = path.join(scopeDir, 'protocol');

  fs.mkdirSync(scopeDir, { recursive: true });
  try {
    if (!fs.existsSync(aliasPackageDir)) {
      fs.symlinkSync(workspacePackageDir, aliasPackageDir, process.platform === 'win32' ? 'junction' : 'dir');
    }
  } catch (error) {
    const err = error as NodeJS.ErrnoException;
    if (err.code !== 'EEXIST') throw error;
  }

  const existingNodePath = env['NODE_PATH'];
  const nodePathEntries = [aliasRoot, ...(existingNodePath ? existingNodePath.split(path.delimiter) : [])].filter(
    Boolean,
  );

  return {
    ...env,
    NODE_PATH: [...new Set(nodePathEntries)].join(path.delimiter),
  };
}

/**
 * Walk up from startDir until testFn returns a truthy value, or reach the root.
 */
function findUp<T>(startDir: string, testFn: (dir: string) => T | undefined): T | undefined {
  const path = require('node:path');
  let currentDir = startDir;
  while (true) {
    const result = testFn(currentDir);
    if (result !== undefined) return result;
    const parentDir = path.dirname(currentDir);
    if (parentDir === currentDir) return undefined;
    currentDir = parentDir;
  }
}

function findWorkspaceProtocolDir(startDir: string): string | undefined {
  const fs = require('node:fs');
  const path = require('node:path');
  return findUp(startDir, (dir) => {
    const candidate = path.join(dir, 'libs', 'protocol');
    return fs.existsSync(path.join(candidate, 'dist', 'index.js')) ? candidate : undefined;
  });
}

function findInstalledProtocolPackageDir(startDir: string): string | undefined {
  const fs = require('node:fs');
  const path = require('node:path');
  return findUp(startDir, (dir) => {
    const candidate = path.join(dir, 'node_modules', '@frontmcp', 'protocol');
    return fs.existsSync(path.join(candidate, 'package.json')) ? candidate : undefined;
  });
}

function findWorkspaceNodeModulesDir(startDir: string): string | undefined {
  const fs = require('node:fs');
  const path = require('node:path');
  return findUp(startDir, (dir) => {
    const candidate = path.join(dir, 'node_modules');
    return fs.existsSync(candidate) ? candidate : undefined;
  });
}

// Re-export port registry utilities
export {
  reservePort,
  getProjectPort,
  getProjectPorts,
  getPortRange,
  releaseAllPorts,
  getReservedPorts,
  E2E_PORT_RANGES,
  type E2EProject,
} from './port-registry';
