/**
 * OpenClaw Gateway WebSocket client — Protocol v3
 *
 * Correct wire format (verified from openclaw/openclaw source):
 *   Request:  { type:"req", id, method, params?, idempotencyKey? }
 *   Response: { type:"res", id, ok:boolean, payload?, error? }
 *   Event:    { type:"event", event, payload?, seq? }
 *
 * Authentication:
 *   1. Server sends "connect.challenge" event with { nonce, timestamp }
 *   2. Client signs the nonce with an ephemeral Ed25519 key
 *   3. Client sends "connect" req with auth.token + device.{id,publicKey,signature,signedAt}
 *   4. Server responds with ok:true → connection is ready
 */

import WebSocket from 'ws';
import * as crypto from 'crypto';

// ── Wire types ────────────────────────────────────────────────────────────────

interface ReqFrame {
  type: 'req';
  id: string;
  method: string;
  params?: unknown;
  idempotencyKey?: string;
}

interface ResFrame {
  type: 'res';
  id: string;
  ok: boolean;
  payload?: unknown;
  error?: { code: string; message: string; retryable?: boolean; retryAfterMs?: number };
}

interface EventFrame {
  type: 'event';
  event: string;
  payload?: unknown;
  seq?: number;
}

type Frame = ReqFrame | ResFrame | EventFrame;

// ── Client ────────────────────────────────────────────────────────────────────

export class OpenClawClient {
  private ws: WebSocket | null = null;
  private pending = new Map<
    string,
    { resolve: (v: unknown) => void; reject: (e: Error) => void }
  >();
  private connected = false;
  private msgCounter = 0;

  // Ephemeral Ed25519 key pair — generated once per client instance
  private readonly deviceId = crypto.randomUUID();
  private readonly keyPair = crypto.generateKeyPairSync('ed25519');

  constructor(
    private readonly gatewayUrl: string,
    private readonly token: string,
  ) {}

  connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      const ws = new WebSocket(this.gatewayUrl);
      this.ws = ws;

      // Whether the handshake promise has settled (prevents double-settle)
      let settled = false;
      const settle = (err?: Error) => {
        if (settled) return;
        settled = true;
        if (err) reject(err);
        else resolve();
      };

      ws.on('message', (raw) => {
        let frame: Frame;
        try {
          frame = JSON.parse(raw.toString());
        } catch {
          return;
        }
        this.handleFrame(frame, settle);
      });

      ws.once('error', (err) => {
        if (!this.connected) settle(err);
      });

      ws.on('close', () => {
        this.connected = false;
        for (const [, p] of this.pending) {
          p.reject(new Error('OpenClaw gateway disconnected'));
        }
        this.pending.clear();
      });

      // Safety timeout for handshake (gateway has 10s timeout itself)
      setTimeout(() => settle(new Error('Handshake timeout')), 12_000);
    });
  }

  private handleFrame(
    frame: Frame,
    settle: (err?: Error) => void,
  ): void {
    // Step 1: Server sends challenge
    if (frame.type === 'event' && frame.event === 'connect.challenge') {
      const { nonce } = frame.payload as { nonce: string; timestamp: number };
      this.sendHandshake(nonce).catch((e) => settle(e));
      return;
    }

    // Step 2: Server acknowledges handshake
    if (frame.type === 'res' && frame.id === '__connect__') {
      if (frame.ok) {
        this.connected = true;
        settle();
      } else {
        settle(new Error(`OpenClaw handshake rejected: ${frame.error?.message ?? frame.error?.code}`));
      }
      return;
    }

    // Regular RPC responses
    if (frame.type === 'res') {
      const p = this.pending.get(frame.id);
      if (!p) return;
      this.pending.delete(frame.id);
      if (frame.ok) {
        p.resolve(frame.payload ?? null);
      } else {
        p.reject(new Error(frame.error?.message ?? frame.error?.code ?? 'RPC error'));
      }
    }
    // Events are ignored by default (not needed for the bridge use case)
  }

  private async sendHandshake(nonce: string): Promise<void> {
    // Sign the nonce (hex string → Buffer) with our ephemeral private key
    const nonceBuffer = Buffer.from(nonce, 'hex');
    const signature = crypto.sign(null, nonceBuffer, this.keyPair.privateKey);
    const pubKeyDer = this.keyPair.publicKey.export({ type: 'spki', format: 'der' });

    const req: ReqFrame = {
      type: 'req',
      id: '__connect__',
      method: 'connect',
      params: {
        minProtocol: 3,
        maxProtocol: 3,
        client: {
          id: this.deviceId,
          version: '1.0.0',
          platform: 'node',
          mode: 'channel',
        },
        role: 'operator',
        scopes: ['operator.read', 'operator.write'],
        auth: { token: this.token },
        device: {
          id: this.deviceId,
          publicKey: pubKeyDer.toString('base64'),
          signature: signature.toString('base64'),
          signedAt: Date.now(),
        },
      },
    };

    this.ws!.send(JSON.stringify(req));
  }

  isConnected(): boolean {
    return this.connected && this.ws?.readyState === WebSocket.OPEN;
  }

  rpc(method: string, params?: unknown, timeoutMs = 30_000): Promise<unknown> {
    return new Promise((resolve, reject) => {
      if (!this.isConnected()) {
        reject(new Error('Not connected to OpenClaw gateway'));
        return;
      }
      const id = String(++this.msgCounter);
      const frame: ReqFrame = { type: 'req', id, method, params };
      this.pending.set(id, { resolve, reject });
      this.ws!.send(JSON.stringify(frame));

      const timer = setTimeout(() => {
        if (this.pending.has(id)) {
          this.pending.delete(id);
          reject(new Error(`RPC timeout: ${method}`));
        }
      }, timeoutMs);
      // Don't keep process alive just for the timer
      if (timer.unref) timer.unref();
    });
  }

  close(): void {
    this.ws?.close();
    this.connected = false;
  }
}
