import { spawn, type ChildProcess } from 'node:child_process';
import { createInterface, type Interface as ReadlineInterface } from 'node:readline';

export interface AcpClientConfig {
  binaryPath: string;
  model: string;
  /** Timeout for individual RPC requests in ms (default: 60000) */
  requestTimeoutMs: number;
  /** Timeout for process initialization in ms (default: 30000) */
  initTimeoutMs: number;
  /** Override spawn for testing */
  spawn?: typeof spawn;
}

interface PendingRequest {
  resolve: (result: unknown) => void;
  reject: (err: Error) => void;
  timer: ReturnType<typeof setTimeout>;
}

/**
 * Low-level ACP (Agent Client Protocol) client.
 * Manages a persistent `gemini --experimental-acp` subprocess and communicates
 * via JSON-RPC 2.0 over NDJSON stdio.
 *
 * Pattern follows StdioUpstream: readline for parsing, pending request map with timeouts.
 */
export class AcpClient {
  private process: ChildProcess | null = null;
  private readline: ReadlineInterface | null = null;
  private pendingRequests = new Map<number, PendingRequest>();
  private nextId = 1;
  private sessionId: string | null = null;
  private ready = false;
  private initPromise: Promise<void> | null = null;
  private readonly config: AcpClientConfig;

  /** Accumulates text chunks from session/update agent_message_chunk during a prompt. */
  private activePromptChunks: string[] = [];

  constructor(config: AcpClientConfig) {
    this.config = config;
  }

  /** Ensure the subprocess is spawned and initialized. Idempotent and lazy. */
  async ensureReady(): Promise<void> {
    if (this.ready && this.process && !this.process.killed) return;

    // If already initializing, wait for it
    if (this.initPromise) return this.initPromise;

    this.initPromise = this.doInit();
    try {
      await this.initPromise;
    } catch (err) {
      this.initPromise = null;
      throw err;
    }
  }

  /** Send a prompt and collect the streamed text response. */
  async prompt(text: string): Promise<string> {
    await this.ensureReady();

    // Set up chunk accumulator
    this.activePromptChunks = [];

    const result = await this.sendRequest('session/prompt', {
      sessionId: this.sessionId,
      prompt: [{ type: 'text', text }],
    }, this.config.requestTimeoutMs) as { stopReason: string };

    const collected = this.activePromptChunks.join('');
    this.activePromptChunks = [];

    if (result.stopReason === 'refusal') {
      throw new Error('Gemini refused to process the prompt');
    }

    return collected;
  }

  /** Kill the subprocess and clean up. */
  dispose(): void {
    this.cleanup();
  }

  /** Check if the subprocess is alive and initialized. */
  get isAlive(): boolean {
    return this.ready && this.process !== null && !this.process.killed;
  }

  // --- Private ---

  private async doInit(): Promise<void> {
    // Clean up any previous state
    this.cleanup();

    this.spawnProcess();
    this.setupReadline();

    // ACP handshake: initialize
    await this.sendRequest('initialize', {
      protocolVersion: 1,
      clientCapabilities: {},
      clientInfo: { name: 'mcpctl', version: '1.0.0' },
    }, this.config.initTimeoutMs);

    // ACP handshake: session/new
    const sessionResult = await this.sendRequest('session/new', {
      cwd: '/tmp',
      mcpServers: [],
    }, this.config.initTimeoutMs) as { sessionId: string };

    this.sessionId = sessionResult.sessionId;
    this.ready = true;
  }

  private spawnProcess(): void {
    const spawnFn = this.config.spawn ?? spawn;
    this.process = spawnFn(this.config.binaryPath, ['--experimental-acp'], {
      stdio: ['pipe', 'pipe', 'pipe'],
      env: process.env,
    });

    this.process.on('exit', () => {
      this.ready = false;
      this.initPromise = null;
      this.sessionId = null;

      // Reject all pending requests
      for (const [id, pending] of this.pendingRequests) {
        clearTimeout(pending.timer);
        pending.reject(new Error('Gemini ACP process exited'));
        this.pendingRequests.delete(id);
      }
    });

    this.process.on('error', (err) => {
      this.ready = false;
      this.initPromise = null;

      for (const [id, pending] of this.pendingRequests) {
        clearTimeout(pending.timer);
        pending.reject(err);
        this.pendingRequests.delete(id);
      }
    });
  }

  private setupReadline(): void {
    if (!this.process?.stdout) return;

    this.readline = createInterface({ input: this.process.stdout });
    this.readline.on('line', (line) => this.handleLine(line));
  }

  private handleLine(line: string): void {
    let msg: Record<string, unknown>;
    try {
      msg = JSON.parse(line) as Record<string, unknown>;
    } catch {
      // Skip non-JSON lines (e.g., debug output on stdout)
      return;
    }

    // Response to a pending request (has 'id')
    if ('id' in msg && msg.id !== undefined && ('result' in msg || 'error' in msg)) {
      this.handleResponse(msg as { id: number; result?: unknown; error?: { code: number; message: string } });
      return;
    }

    // Notification (has 'method', no 'id')
    if ('method' in msg && !('id' in msg)) {
      this.handleNotification(msg as { method: string; params?: Record<string, unknown> });
      return;
    }

    // Request from agent (has 'method' AND 'id') — agent asking us for something
    if ('method' in msg && 'id' in msg) {
      this.handleAgentRequest(msg as { id: number; method: string; params?: Record<string, unknown> });
      return;
    }
  }

  private handleResponse(msg: { id: number; result?: unknown; error?: { code: number; message: string } }): void {
    const pending = this.pendingRequests.get(msg.id);
    if (!pending) return;

    clearTimeout(pending.timer);
    this.pendingRequests.delete(msg.id);

    if (msg.error) {
      pending.reject(new Error(`ACP error ${msg.error.code}: ${msg.error.message}`));
    } else {
      pending.resolve(msg.result);
    }
  }

  private handleNotification(msg: { method: string; params?: Record<string, unknown> }): void {
    if (msg.method !== 'session/update' || !msg.params) return;

    const update = msg.params.update as Record<string, unknown> | undefined;
    if (!update) return;

    // Collect text from agent_message_chunk
    if (update.sessionUpdate === 'agent_message_chunk') {
      const content = update.content;
      // Gemini ACP sends content as a single object {type, text} or an array [{type, text}]
      const blocks: Array<{ type: string; text?: string }> = Array.isArray(content)
        ? content as Array<{ type: string; text?: string }>
        : content && typeof content === 'object'
          ? [content as { type: string; text?: string }]
          : [];
      for (const block of blocks) {
        if (block.type === 'text' && block.text) {
          this.activePromptChunks.push(block.text);
        }
      }
    }
  }

  /** Handle requests from the agent (e.g., session/request_permission). Reject them all. */
  private handleAgentRequest(msg: { id: number; method: string; params?: Record<string, unknown> }): void {
    if (!this.process?.stdin) return;

    if (msg.method === 'session/request_permission') {
      // Reject permission requests — we don't want tool use
      const response = JSON.stringify({
        jsonrpc: '2.0',
        id: msg.id,
        result: { outcome: { outcome: 'cancelled' } },
      });
      this.process.stdin.write(response + '\n');
    } else {
      // Unknown method — return error
      const response = JSON.stringify({
        jsonrpc: '2.0',
        id: msg.id,
        error: { code: -32601, message: 'Method not supported' },
      });
      this.process.stdin.write(response + '\n');
    }
  }

  private sendRequest(method: string, params: Record<string, unknown>, timeoutMs: number): Promise<unknown> {
    if (!this.process?.stdin) {
      return Promise.reject(new Error('ACP process not started'));
    }

    const id = this.nextId++;

    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pendingRequests.delete(id);
        // Kill the process on timeout — it's hung
        this.cleanup();
        reject(new Error(`ACP request '${method}' timed out after ${timeoutMs}ms`));
      }, timeoutMs);

      this.pendingRequests.set(id, { resolve, reject, timer });

      const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params });
      this.process!.stdin!.write(msg + '\n');
    });
  }

  private cleanup(): void {
    this.ready = false;
    this.initPromise = null;
    this.sessionId = null;
    this.activePromptChunks = [];

    // Reject all pending requests
    for (const [id, pending] of this.pendingRequests) {
      clearTimeout(pending.timer);
      pending.reject(new Error('ACP client disposed'));
      this.pendingRequests.delete(id);
    }

    if (this.readline) {
      this.readline.close();
      this.readline = null;
    }

    if (this.process) {
      this.process.kill('SIGTERM');
      this.process = null;
    }
  }
}
