import { randomUUID } from "node:crypto";
import WebSocket from "ws";
import type {
  ChatMessage,
  GatewayHealth,
  GatewayStatus,
  LogEntry,
  MessageRole,
  PromptResult,
  SessionSummary
} from "@qjclaw/shared-types";

export interface SignedDeviceProof {
  id: string;
  publicKey: string;
  signature: string;
  signedAt: number;
  nonce: string;
}

export interface GatewayDeviceIdentityProvider {
  signConnectChallenge(input: {
    clientId: string;
    clientMode: string;
    role: string;
    scopes: string[];
    token?: string;
    nonce: string;
  }): Promise<SignedDeviceProof>;
}

interface RpcRequestFrame {
  type: "req";
  id: string;
  method: string;
  params?: Record<string, unknown>;
}

interface PendingRequest {
  resolve: (value: unknown) => void;
  reject: (reason?: unknown) => void;
  timer: NodeJS.Timeout;
}

interface PendingChatRun {
  resolve: (value: ChatMessage) => void;
  reject: (reason?: unknown) => void;
  timer: NodeJS.Timeout;
  sessionKey: string;
  accumulatedText: string;
  onDelta?: (value: GatewayPromptStreamDelta) => void;
  onStatus?: (value: GatewayPromptStreamStatus) => void;
  onCompleted?: (value: { sessionId: string; runId: string; reply: ChatMessage }) => void;
  onError?: (value: { sessionId: string; runId?: string; error: Error }) => void;
}

interface GatewayClientOptions {
  url: string;
  token?: string;
  deviceToken?: string;
  deviceIdentity?: GatewayDeviceIdentityProvider;
  onDeviceToken?: (deviceToken: string) => Promise<void> | void;
  /** Maximum idle time (ms) between stream events before chat run times out. Default: 120_000 (2 min). */
  chatRunTimeoutMs?: number;
}

interface GatewayErrorShape {
  code?: string;
  message?: string;
  details?: {
    code?: string;
    reason?: string;
    recommendedNextStep?: string;
  };
}

interface ConnectHelloPayload {
  protocol?: number;
  auth?: {
    deviceToken?: string;
  };
  server?: {
    version?: string;
    connId?: string;
  };
  features?: {
    methods?: string[];
  };
  snapshot?: {
    health?: Record<string, unknown>;
  };
}

interface SessionsListResult {
  sessions?: Array<{
    key?: string;
    updatedAt?: number;
    sessionId?: string;
    model?: string;
    lastChannel?: string;
    channel?: string;
  }>;
}

interface ChatHistoryResult {
  sessionKey?: string;
  messages?: Array<{
    role?: string;
    timestamp?: number;
    content?: Array<{ type?: string; text?: string }>;
  }>;
}

const CHANNEL_LABELS: Record<string, string> = {
  feishu: "飞书",
  lark: "Lark"
};

function formatChannelSessionTitle(sessionKey: string, channel: string): string {
  const label = CHANNEL_LABELS[channel] ?? channel;
  const parts = sessionKey.split(":");
  // agent:main:<channel>:<peerKind>:<peerId>
  if (parts.length >= 5) {
    const peerKind = parts[3];
    const peerId = parts.slice(4).join(":");
    const kindLabel = peerKind === "direct" ? "私聊" : peerKind === "group" ? "群聊" : peerKind;
    const shortId = peerId.length > 12 ? `${peerId.slice(0, 6)}...${peerId.slice(-6)}` : peerId;
    return `${label}${kindLabel} ${shortId}`;
  }
  return `${label} ${sessionKey}`;
}

export interface GatewayPromptStreamStart {
  sessionId: string;
  runId: string;
  completion: Promise<ChatMessage>;
}

export interface GatewayCancelChatRunResult {
  runId: string;
  localCancelled: boolean;
  remoteCancelled: boolean;
}

export interface GatewayPromptStreamDelta {
  sessionId: string;
  runId: string;
  textDelta: string;
  fullText?: string;
}

export interface GatewayPromptStreamStatus {
  sessionId: string;
  runId?: string;
  stage: string;
  label: string;
  detail?: string;
}

export interface GatewayPromptStreamHandlers {
  requestMetadata?: {
    projectId?: string;
    skillId?: string;
  };
  onStarted?: (value: GatewayPromptStreamStart) => void;
  onDelta?: (value: GatewayPromptStreamDelta) => void;
  onStatus?: (value: GatewayPromptStreamStatus) => void;
  onCompleted?: (value: { sessionId: string; runId: string; reply: ChatMessage }) => void;
  onError?: (value: { sessionId: string; runId?: string; error: Error }) => void;
}

export interface GatewayConnectParamsInput {
  token?: string;
  deviceToken?: string;
  device?: SignedDeviceProof;
}

export function buildGatewayConnectParams(input: GatewayConnectParamsInput = {}) {
  const auth = input.token || input.deviceToken
    ? {
        token: input.token,
        deviceToken: input.deviceToken
      }
    : undefined;

  return {
    minProtocol: 3,
    maxProtocol: 3,
    client: {
      id: GATEWAY_CLIENT_ID,
      version: "qianjiangclaw-desktop",
      platform: process.platform,
      mode: GATEWAY_CLIENT_MODE
    },
    role: GATEWAY_CLIENT_ROLE,
    scopes: [...GATEWAY_CLIENT_SCOPES],
    caps: [...GATEWAY_CLIENT_CAPS],
    auth,
    device: input.device,
    locale: "zh-CN",
    userAgent: "qianjiangclaw-desktop"
  };
}

export const GATEWAY_CLIENT_ID = "gateway-client";
export const GATEWAY_CLIENT_MODE = "backend";
export const GATEWAY_CLIENT_ROLE = "operator";
export const GATEWAY_CLIENT_SCOPES = ["operator.read", "operator.write"] as const;
export const GATEWAY_CLIENT_CAPS = ["tool-events"] as const;
const ANSI_ESCAPE_PATTERN = /\u001B\[[0-9;?]*[ -/]*[@-~]/g;
const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000B-\u001A\u001C-\u001F\u007F]/g;
const LOG_PREFIX_PATTERN = /^[^A-Za-z0-9\[{(]+(?=[A-Za-z])/;
const STRUCTURED_LOG_KEYWORDS = ["event", "res", "connect", "hello-ok", "open", "close", "listening", "started", "Registered plugin command", "heartbeat:", "gateway ", "log file:", "Browser control listening"] as const;

export class GatewayClient {
  private url: string;
  private token?: string;
  private deviceToken?: string;
  private readonly deviceIdentity?: GatewayDeviceIdentityProvider;
  private readonly onDeviceToken?: (deviceToken: string) => Promise<void> | void;
  private readonly chatRunTimeoutMs: number;
  private websocket?: WebSocket;
  private statusSnapshot: GatewayStatus;
  private readonly logs: LogEntry[] = [];
  private readonly pendingRequests = new Map<string, PendingRequest>();
  private readonly pendingChatRuns = new Map<string, PendingChatRun>();
  private intentionalClose = false;
  private handshakeSent = false;
  private requestCounter = 0;

  constructor(options: GatewayClientOptions) {
    this.url = options.url;
    this.token = options.token;
    this.deviceToken = options.deviceToken;
    this.deviceIdentity = options.deviceIdentity;
    this.onDeviceToken = options.onDeviceToken;
    this.chatRunTimeoutMs = options.chatRunTimeoutMs ?? 120_000;
    this.statusSnapshot = this.createStatus("unknown", "Gateway client initialized.");
    this.appendLog("info", `Gateway client initialized for ${this.url}.`);
  }

  async reconfigure(url: string, token = this.token, deviceToken = this.deviceToken): Promise<void> {
    await this.disconnect();
    this.url = url;
    this.token = token;
    this.deviceToken = deviceToken;
    this.statusSnapshot = this.createStatus("unknown", "Gateway configuration updated.");
    this.appendLog("info", `Gateway client reconfigured for ${this.url}.`);
  }

  async connect(timeoutMs = 7000): Promise<GatewayStatus> {
    if (this.websocket && this.websocket.readyState === WebSocket.OPEN && this.statusSnapshot.state === "connected") {
      return this.status();
    }

    this.intentionalClose = false;
    this.handshakeSent = false;
    this.statusSnapshot = this.createStatus("connecting", `Connecting to ${this.url}...`);
    this.appendLog("info", `Connecting to ${this.url}.`);

    return new Promise<GatewayStatus>((resolve, reject) => {
      let settled = false;
      let suppressNextClose = false;
      let ws: WebSocket | undefined;
      const connectTimeout = setTimeout(() => {
        const error = new Error(`Timed out while connecting to ${this.url}.`);
        this.failConnection(error.message);
        suppressNextClose = true;
        this.resetTransport(ws);
        cleanup();
        reject(error);
      }, timeoutMs);

      const cleanup = () => {
        clearTimeout(connectTimeout);
      };

      ws = new WebSocket(this.url);
      this.websocket = ws;

      ws.on("open", () => {
        this.appendLog("info", "WebSocket transport opened. Waiting for connect challenge.");
      });

      ws.on("message", async (data: WebSocket.RawData) => {
        try {
          const frame = JSON.parse(data.toString()) as Record<string, unknown>;

          if (frame.type === "event") {
            await this.handleEvent(frame);
            return;
          }

          if (frame.type === "res") {
            const connectResponse = this.handleConnectResponse(frame, cleanup, resolve, reject, () => {
              suppressNextClose = true;
              this.resetTransport(ws);
            });
            if (connectResponse) {
              settled = true;
              return;
            }

            this.handlePendingResponse(frame);
          }
        } catch (error) {
          const message = error instanceof Error ? error.message : String(error);
          this.appendLog("warn", `Failed to handle Gateway frame: ${message}`);
          if (!settled && this.handshakeSent) {
            this.failConnection(message);
            suppressNextClose = true;
            this.resetTransport(ws);
            cleanup();
            reject(new Error(message));
          }
        }
      });

      ws.on("close", (code: number) => {
        if (suppressNextClose) {
          suppressNextClose = false;
          this.resetTransport(ws);
          return;
        }

        if (this.intentionalClose) {
          this.statusSnapshot = this.createStatus("disconnected", "Gateway disconnected.");
          this.appendLog("info", `Gateway connection closed intentionally (${code}).`);
          this.resetTransport(ws);
          return;
        }

        const message = settled
          ? `Gateway connection closed (${code}).`
          : `Gateway closed during connect (${code}).`;
        this.resetTransport(ws);
        this.failConnection(message);
        if (!settled) {
          cleanup();
          reject(new Error(message));
        }
      });

      ws.on("error", () => {
        const message = `Failed to connect to ${this.url}. Check that OpenClaw Gateway is reachable.`;
        this.failConnection(message);
        if (!settled) {
          this.resetTransport(ws);
          cleanup();
          reject(new Error(message));
        }
      });
    });
  }

  async disconnect(): Promise<GatewayStatus> {
    for (const pending of this.pendingRequests.values()) {
      clearTimeout(pending.timer);
      pending.reject(new Error("Gateway connection was closed."));
    }
    this.pendingRequests.clear();

    for (const pending of this.pendingChatRuns.values()) {
      clearTimeout(pending.timer);
      pending.reject(new Error("Gateway connection was closed."));
    }
    this.pendingChatRuns.clear();

    if (this.websocket) {
      this.intentionalClose = true;
      this.websocket.close();
      this.websocket = undefined;
    }
    this.handshakeSent = false;

    this.statusSnapshot = this.createStatus("disconnected", "Gateway disconnected.");
    this.appendLog("info", "Gateway connection closed by desktop app.");
    return this.status();
  }

  async reconnect(timeoutMs = 7000): Promise<GatewayStatus> {
    await this.disconnect();
    return this.connect(timeoutMs);
  }

  async status(): Promise<GatewayStatus> {
    if (this.websocket && this.websocket.readyState === WebSocket.OPEN && this.statusSnapshot.state === "connected") {
      try {
        const result = (await this.request("status", {})) as Record<string, unknown>;
        const runtimeVersion = typeof result.runtimeVersion === "string" ? result.runtimeVersion : this.statusSnapshot.version;
        this.statusSnapshot = {
          ...this.statusSnapshot,
          version: runtimeVersion,
          message: "Gateway connected.",
          availableMethods: this.statusSnapshot.availableMethods
        };
      } catch (error) {
        this.appendLog("warn", `status RPC failed: ${error instanceof Error ? error.message : String(error)}`);
      }
    }

    return { ...this.statusSnapshot };
  }

  async health(): Promise<GatewayHealth> {
    if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN || this.statusSnapshot.state !== "connected") {
      return {
        ok: false,
        latencyMs: 0,
        checkedAt: new Date().toISOString(),
        transport: "websocket",
        details: this.statusSnapshot.lastError ?? this.statusSnapshot.message ?? "Gateway is not connected."
      };
    }

    const started = Date.now();
    try {
      const result = (await this.request("health", {})) as Record<string, unknown>;
      return {
        ok: Boolean(result.ok ?? true),
        latencyMs: Math.max(Date.now() - started, 1),
        checkedAt: new Date().toISOString(),
        transport: "websocket",
        details: `defaultAgent=${String(result.defaultAgentId ?? "main")}`
      };
    } catch (error) {
      return {
        ok: false,
        latencyMs: Math.max(Date.now() - started, 1),
        checkedAt: new Date().toISOString(),
        transport: "websocket",
        details: error instanceof Error ? error.message : String(error)
      };
    }
  }

  async tailLogs(limit = 200): Promise<LogEntry[]> {
    if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN || this.statusSnapshot.state !== "connected") {
      return this.logs.slice(-limit);
    }

    try {
      const result = (await this.request("logs.tail", { limit })) as Record<string, unknown>;
      const lines = Array.isArray(result.lines) ? result.lines : [];
      const mapped = lines.map((line) => this.parseGatewayLogLine(String(line))).filter(Boolean) as LogEntry[];
      return mapped.slice(-limit);
    } catch (error) {
      this.appendLog("warn", `logs.tail RPC failed: ${error instanceof Error ? error.message : String(error)}`);
      return this.logs.slice(-limit);
    }
  }

  async listSessions(): Promise<SessionSummary[]> {
    await this.ensureConnected();
    const result = (await this.request("sessions.list", { limit: 50 })) as SessionsListResult;
    return (result.sessions ?? []).map((session) => ({
      id: session.key ?? session.sessionId ?? randomUUID(),
      title: session.key ?? session.model ?? "Session",
      updatedAt: new Date(session.updatedAt ?? Date.now()).toISOString(),
      channelType: session.lastChannel ?? session.channel
    }));
  }

  async listChannelSessions(channel?: string): Promise<SessionSummary[]> {
    const all = await this.listSessions();
    if (!channel) {
      return all.filter((s) => Boolean(s.channelType));
    }
    return all.filter((s) => s.channelType === channel).map((s) => ({
      ...s,
      title: formatChannelSessionTitle(s.id, s.channelType as string)
    }));
  }

  async listMessages(sessionId: string): Promise<ChatMessage[]> {
    await this.ensureConnected();
    const sessionKeys = [sessionId];
    if (!sessionId.startsWith("agent:")) {
      sessionKeys.push(`agent:main:${sessionId}`);
    }

    for (let index = 0; index < sessionKeys.length; index += 1) {
      const sessionKey = sessionKeys[index];
      const result = (await this.request("chat.history", { sessionKey, limit: 100 })) as ChatHistoryResult;
      const messages = (result.messages ?? []).map((message, messageIndex) => ({
        id: `${sessionKey}:${message.timestamp ?? messageIndex}:${messageIndex}`,
        role: this.normalizeChatRole(message.role),
        content: this.flattenContent(message.content),
        createdAt: new Date(message.timestamp ?? Date.now()).toISOString()
      }));
      if (messages.length > 0 || index === sessionKeys.length - 1) {
        return messages;
      }
    }

    return [];
  }

  async sendPrompt(sessionId: string, prompt: string, requestMetadata?: GatewayPromptStreamHandlers["requestMetadata"]): Promise<PromptResult> {
    const stream = await this.streamPrompt(sessionId, prompt, { requestMetadata });
    const reply = await stream.completion;
    return {
      sessionId: stream.sessionId,
      reply
    };
  }

  async streamPrompt(sessionId: string, prompt: string, handlers: GatewayPromptStreamHandlers = {}): Promise<GatewayPromptStreamStart> {
    await this.ensureConnected();
    const result = (await this.request("chat.send", {
      sessionKey: sessionId,
      message: prompt,
      idempotencyKey: randomUUID(),
      projectId: handlers.requestMetadata?.projectId,
      skillId: handlers.requestMetadata?.skillId
    })) as { runId?: string; status?: string };

    const runId = result.runId;
    if (!runId) {
      throw new Error("Gateway did not return a runId for chat.send.");
    }

    const completion = this.trackChatRun(runId, sessionId, handlers);
    handlers.onStarted?.({ sessionId, runId, completion });
    return { sessionId, runId, completion };
  }

  async cancelChatRun(runId: string): Promise<GatewayCancelChatRunResult> {
    const pending = this.pendingChatRuns.get(runId);
    if (pending) {
      clearTimeout(pending.timer);
      this.pendingChatRuns.delete(runId);
      pending.resolve({
        id: `${pending.sessionKey}:${runId}:cancelled`,
        role: "assistant",
        content: pending.accumulatedText,
        createdAt: new Date().toISOString()
      });
    }

    const availableMethods = this.statusSnapshot.availableMethods ?? [];
    if (!availableMethods.includes("chat.cancel")) {
      return {
        runId,
        localCancelled: Boolean(pending),
        remoteCancelled: false
      };
    }

    try {
      await this.request("chat.cancel", { runId });
      return {
        runId,
        localCancelled: Boolean(pending),
        remoteCancelled: true
      };
    } catch (error) {
      this.appendLog("warn", `Gateway chat.cancel failed for ${runId}: ${error instanceof Error ? error.message : String(error)}`);
      return {
        runId,
        localCancelled: Boolean(pending),
        remoteCancelled: false
      };
    }
  }

  private async handleEvent(frame: Record<string, unknown>): Promise<void> {
    const eventName = String(frame.event ?? "unknown");

    if (eventName === "connect.challenge" && !this.handshakeSent) {
      this.handshakeSent = true;
      const nonce = String((frame.payload as Record<string, unknown> | undefined)?.nonce ?? "");
      if (!nonce) {
        throw new Error("Gateway did not provide a challenge nonce.");
      }

      const device = this.deviceIdentity
        ? await this.deviceIdentity.signConnectChallenge({
            clientId: GATEWAY_CLIENT_ID,
            clientMode: GATEWAY_CLIENT_MODE,
            role: GATEWAY_CLIENT_ROLE,
            scopes: [...GATEWAY_CLIENT_SCOPES],
            token: this.token,
            nonce
          })
        : undefined;

      this.sendFrame({
        type: "req",
        id: "1",
        method: "connect",
        params: buildGatewayConnectParams({
          token: this.token,
          deviceToken: this.deviceToken,
          device
        })
      });
      this.appendLog("info", "Sent Gateway connect handshake.");
      return;
    }

    if (eventName === "chat") {
      const payload = (frame.payload ?? frame) as Record<string, unknown>;
      const runId = this.findStringDeep(payload, ["runId", "run_id"]);
      const state = this.findStringDeep(payload, ["state", "status"]);
      if (runId) {
        this.emitChatDelta(runId, payload);
        if (state === "final") {
          const pending = this.pendingChatRuns.get(runId);
          const reply = await this.resolveCompletedChatReply(runId, payload);
          if (!reply.content.trim() && !pending?.accumulatedText.trim()) {
            // Gateway signaled final but the reply is empty and no deltas arrived.
            // Keep the pending run alive — an agent error event may follow.
            this.appendLog("warn", `chat final event received with empty content for run ${runId}; waiting for agent error or timeout.`);
            return;
          }
          this.completeChatRun(runId, reply);
        }
      }
    }

    if (eventName === "agent") {
      const payload = (frame.payload ?? frame) as Record<string, unknown>;
      const runId = this.findStringDeep(payload, ["runId", "run_id"]);
      const stream = this.findStringDeep(payload, ["stream"]);
      if (stream === "error") {
        const message = this.extractTextCandidate(payload.data) ?? this.extractTextCandidate(payload) ?? JSON.stringify(payload.data ?? {});
        console.error("[gateway-client] agent stream error:", message, "runId:", runId);
        this.appendLog("warn", "Agent stream error: " + message);
        if (this.isRecoverableAgentStreamError(message)) {
          return;
        }
        if (runId) {
          this.failChatRun(runId, new Error(message));
        }
      } else if (!stream || stream === "assistant") {
        if (runId) {
          this.emitChatDelta(runId, payload.data ?? payload);
        }
      } else if (runId) {
        const status = this.describeAgentStatus(payload, stream);
        if (status) {
          this.emitChatStatus(runId, status);
        }
      }
    }

    if (eventName === "health") {
      this.appendLog("info", "Gateway health event received.");
      return;
    }

    this.appendLog("info", `Gateway event received: ${eventName}.`);
  }

  private handleConnectResponse(
    frame: Record<string, unknown>,
    cleanup: () => void,
    resolve: (value: GatewayStatus) => void,
    reject: (reason?: unknown) => void,
    closeTransport: () => void
  ): boolean {
    if (frame.id !== "1") {
      return false;
    }

    if (frame.ok === false) {
      const message = this.formatGatewayError(frame.error as GatewayErrorShape | undefined);
      this.failConnection(message);
      closeTransport();
      cleanup();
      reject(new Error(message));
      return true;
    }

    const payload = (frame.payload ?? frame.result ?? {}) as ConnectHelloPayload;
    const nextDeviceToken = typeof payload.auth?.deviceToken === "string" ? payload.auth.deviceToken : undefined;

    if (nextDeviceToken) {
      this.deviceToken = nextDeviceToken;
      void this.onDeviceToken?.(nextDeviceToken);
    }

    this.statusSnapshot = {
      ...this.createStatus("connected", "Gateway connection established."),
      version: payload.server?.version ?? "unknown",
      lastConnectedAt: new Date().toISOString(),
      availableMethods: payload.features?.methods ?? []
    };
    this.appendLog("info", `Gateway connected at ${this.statusSnapshot.host}:${this.statusSnapshot.port}.`);
    cleanup();
    resolve(this.statusSnapshot);
    return true;
  }

  private handlePendingResponse(frame: Record<string, unknown>): void {
    const id = typeof frame.id === "string" ? frame.id : undefined;
    if (!id) {
      return;
    }

    const pending = this.pendingRequests.get(id);
    if (!pending) {
      return;
    }

    clearTimeout(pending.timer);
    this.pendingRequests.delete(id);
    if (frame.ok === false) {
      pending.reject(new Error(this.formatGatewayError(frame.error as GatewayErrorShape | undefined)));
    } else {
      pending.resolve(frame.result ?? frame.payload ?? frame);
    }
  }

  private async request(method: string, params: Record<string, unknown>): Promise<unknown> {
    await this.ensureConnected();

    return new Promise((resolve, reject) => {
      const id = String(++this.requestCounter + 1);
      const timer = setTimeout(() => {
        this.pendingRequests.delete(id);
        reject(new Error(`Gateway request timed out: ${method}`));
      }, 30000);

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

      this.sendFrame({
        type: "req",
        id,
        method,
        params
      });
    });
  }

  private trackChatRun(runId: string, sessionKey: string, handlers: GatewayPromptStreamHandlers): Promise<ChatMessage> {
    return new Promise((resolve, reject) => {
      this.pendingChatRuns.set(runId, {
        resolve,
        reject,
        timer: this.createChatRunTimer(runId, sessionKey),
        sessionKey,
        accumulatedText: "",
        onDelta: handlers.onDelta,
        onStatus: handlers.onStatus,
        onCompleted: handlers.onCompleted,
        onError: handlers.onError
      });
    });
  }

  private createChatRunTimer(runId: string, sessionKey: string): NodeJS.Timeout {
    return setTimeout(async () => {
      const pending = this.pendingChatRuns.get(runId);
      if (!pending) {
        return;
      }
      this.pendingChatRuns.delete(runId);
      try {
        const history = await this.listMessages(sessionKey);
        const assistant = [...history].reverse().find((message) => message.role === "assistant");
        if (assistant) {
          pending.onCompleted?.({ sessionId: sessionKey, runId, reply: assistant });
          pending.resolve(assistant);
          return;
        }
      } catch {
      }
      const error = new Error("Timed out waiting for chat completion.");
      pending.onError?.({ sessionId: sessionKey, runId, error });
      pending.reject(error);
    }, this.chatRunTimeoutMs);
  }

  private refreshChatRunTimer(runId: string): void {
    const pending = this.pendingChatRuns.get(runId);
    if (!pending) {
      return;
    }

    clearTimeout(pending.timer);
    pending.timer = this.createChatRunTimer(runId, pending.sessionKey);
  }

  private emitChatDelta(runId: string, payload: unknown): void {
    const pending = this.pendingChatRuns.get(runId);
    if (!pending) {
      return;
    }

    const nextFullText = this.extractTextCandidate(payload);
    if (!nextFullText || nextFullText.length < pending.accumulatedText.length || nextFullText === pending.accumulatedText) {
      return;
    }

    const textDelta = nextFullText.startsWith(pending.accumulatedText)

      ? nextFullText.slice(pending.accumulatedText.length)
      : nextFullText;

    pending.accumulatedText = nextFullText;
    this.refreshChatRunTimer(runId);
    pending.onDelta?.({
      sessionId: pending.sessionKey,
      runId,
      textDelta,
      fullText: nextFullText
    });
  }

  private emitChatStatus(runId: string, status: Omit<GatewayPromptStreamStatus, "sessionId" | "runId">): void {
    const pending = this.pendingChatRuns.get(runId);
    if (!pending) {
      return;
    }

    this.refreshChatRunTimer(runId);
    pending.onStatus?.({
      sessionId: pending.sessionKey,
      runId,
      ...status
    });
  }

  private async resolveCompletedChatReply(runId: string, payload: Record<string, unknown>): Promise<ChatMessage> {
    const pending = this.pendingChatRuns.get(runId);
    const reply = this.buildChatMessage(runId, payload, "");
    if (reply.content.trim()) {
      return reply;
    }
    if (!pending) {
      return reply;
    }

    try {
      const history = await this.listMessages(pending.sessionKey);
      const assistant = [...history].reverse().find((message) => message.role === "assistant" && message.content.trim());
      if (assistant) {
        return assistant;
      }
    } catch {
    }

    if (pending?.accumulatedText.trim()) {
      return this.buildAccumulatedChatMessage(pending.sessionKey, runId, pending.accumulatedText);
    }

    return reply;
  }

  private describeAgentStatus(payload: Record<string, unknown>, stream: string): Omit<GatewayPromptStreamStatus, "sessionId" | "runId"> | null {
    const normalizedStage = stream.trim().toLowerCase();
    const toolName = this.findStringDeep(payload, ["toolName", "tool_name", "name"]);
    const detail = this.extractTextCandidate(payload.data) ?? this.extractTextCandidate(payload);
    const label = this.buildStatusLabel(normalizedStage, toolName);
    const compactDetail = detail && detail !== label ? detail.slice(0, 240) : undefined;

    if (!label && !compactDetail) {
      return null;
    }

    return {
      stage: stream,
      label: label || "\u6b63\u5728\u6574\u7406\u4e2d\u95f4\u7ed3\u679c",
      detail: compactDetail
    };
  }

  private buildStatusLabel(stage: string, toolName?: string): string {
    if (stage.includes("reason") || stage.includes("think")) {
      return "\u6b63\u5728\u7406\u89e3\u4f60\u7684\u95ee\u9898";
    }
    if (stage.includes("tool") && stage.includes("result")) {
      return toolName ? `${toolName} \u5df2\u8fd4\u56de\u4fe1\u606f` : "\u5df2\u62ff\u5230\u8865\u5145\u4fe1\u606f";
    }
    if (stage.includes("tool") || stage.includes("call")) {
      return toolName ? `\u6b63\u5728\u8c03\u7528 ${toolName}` : "\u6b63\u5728\u8c03\u7528\u8f85\u52a9\u80fd\u529b";
    }
    if (stage.includes("search") || stage.includes("fetch") || stage.includes("browser") || stage.includes("web")) {
      return toolName ? `\u6b63\u5728\u901a\u8fc7 ${toolName} \u67e5\u627e\u4fe1\u606f` : "\u6b63\u5728\u67e5\u627e\u8865\u5145\u4fe1\u606f";
    }
    if (stage.includes("plan") || stage.includes("route")) {
      return "\u6b63\u5728\u5b89\u6392\u5904\u7406\u6b65\u9aa4";
    }
    return toolName ? `\u6b63\u5728\u6574\u7406 ${toolName} \u8fd4\u56de\u7684\u4fe1\u606f` : "\u6b63\u5728\u6574\u7406\u4e2d\u95f4\u7ed3\u679c";
  }

  private isRecoverableAgentStreamError(message: string): boolean {
    try {
      const parsed = JSON.parse(message) as { reason?: unknown };
      return parsed.reason === "seq gap";
    } catch {
      return message.includes('"reason":"seq gap"') || message.includes("seq gap");
    }
  }

  private completeChatRun(runId: string, reply: ChatMessage): void {
    const pending = this.pendingChatRuns.get(runId);
    if (!pending) {
      return;
    }

    clearTimeout(pending.timer);
    this.pendingChatRuns.delete(runId);
    pending.onCompleted?.({ sessionId: pending.sessionKey, runId, reply });
    pending.resolve(reply);
  }

  private failChatRun(runId: string, error: Error): void {
    const pending = this.pendingChatRuns.get(runId);
    if (!pending) {
      console.error("[gateway-client] failChatRun: no pending run for", runId, "error:", error.message);
      return;
    }

    console.error("[gateway-client] failChatRun: failing run", runId, "error:", error.message);
    clearTimeout(pending.timer);
    this.pendingChatRuns.delete(runId);
    pending.onError?.({ sessionId: pending.sessionKey, runId, error });
    pending.reject(error);
  }

  private buildChatMessage(runId: string, payload: Record<string, unknown>, fallbackContent?: string): ChatMessage {
    const pending = this.pendingChatRuns.get(runId);
    const message = this.findRecordDeep(payload, ["message"]);
    const completedContent = this.extractTextCandidate(message);
    const fallbackText = fallbackContent ?? pending?.accumulatedText ?? "";
    const content = completedContent?.trim() ? completedContent : fallbackText;
    const timestamp = this.findNumberDeep(message ?? payload, ["timestamp", "createdAt", "created_at"]);
    return {
      id: `${pending?.sessionKey ?? "session"}:${runId}:final`,
      role: "assistant",
      content,
      createdAt: new Date(timestamp ?? Date.now()).toISOString()

    };
  }

  private buildAccumulatedChatMessage(sessionKey: string, runId: string, content: string): ChatMessage {
    return {
      id: `${sessionKey}:${runId}:accumulated`,
      role: "assistant",
      content,
      createdAt: new Date().toISOString()
    };
  }

  private normalizeChatRole(role: unknown): MessageRole {
    if (role === "system" || role === "user" || role === "assistant" || role === "tool" || role === "toolResult") {
      return role;
    }
    return "system";
  }

  private extractTextCandidate(value: unknown): string | undefined {
    if (typeof value === "string") {
      const normalized = value.replace(/\r\n/g, "\n");
      return normalized.length ? normalized : undefined;
    }

    if (Array.isArray(value)) {
      const joined = value

        .map((item) => this.extractTextCandidate(item))
        .filter((item): item is string => Boolean(item))
        .join("\n");
      return joined || undefined;
    }

    if (!value || typeof value !== "object") {
      return undefined;
    }

    const record = value as Record<string, unknown>;
    if (Array.isArray(record.content)) {
      const flattened = this.flattenContent(record.content as Array<{ type?: string; text?: string }>);
      if (flattened) {
        return flattened;
      }
    }

    for (const key of ["textDelta", "delta", "text", "content", "message", "data", "output", "response"]) {
      if (!(key in record)) {
        continue;
      }
      const candidate = this.extractTextCandidate(record[key]);
      if (candidate) {
        return candidate;
      }
    }

    return undefined;
  }

  private findStringDeep(value: unknown, keys: string[]): string | undefined {
    if (!value || typeof value !== "object") {
      return undefined;
    }

    if (Array.isArray(value)) {
      for (const item of value) {
        const nested = this.findStringDeep(item, keys);
        if (nested) {
          return nested;
        }
      }
      return undefined;
    }

    const record = value as Record<string, unknown>;
    for (const key of keys) {
      if (typeof record[key] === "string") {
        return record[key] as string;
      }
    }

    for (const nestedValue of Object.values(record)) {
      const nested = this.findStringDeep(nestedValue, keys);
      if (nested) {
        return nested;
      }
    }

    return undefined;
  }

  private findNumberDeep(value: unknown, keys: string[]): number | undefined {
    if (!value || typeof value !== "object") {
      return undefined;
    }

    if (Array.isArray(value)) {
      for (const item of value) {
        const nested = this.findNumberDeep(item, keys);
        if (typeof nested === "number") {
          return nested;
        }
      }
      return undefined;
    }

    const record = value as Record<string, unknown>;
    for (const key of keys) {
      if (typeof record[key] === "number" && Number.isFinite(record[key])) {
        return record[key] as number;
      }
    }

    for (const nestedValue of Object.values(record)) {
      const nested = this.findNumberDeep(nestedValue, keys);
      if (typeof nested === "number") {
        return nested;
      }
    }

    return undefined;
  }

  private findRecordDeep(value: unknown, keys: string[]): Record<string, unknown> | undefined {
    if (!value || typeof value !== "object") {
      return undefined;
    }

    if (Array.isArray(value)) {
      for (const item of value) {
        const nested = this.findRecordDeep(item, keys);
        if (nested) {
          return nested;
        }
      }
      return undefined;
    }

    const record = value as Record<string, unknown>;
    for (const key of keys) {
      const candidate = record[key];
      if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
        return candidate as Record<string, unknown>;
      }
    }

    for (const nestedValue of Object.values(record)) {
      const nested = this.findRecordDeep(nestedValue, keys);
      if (nested) {
        return nested;
      }
    }

    return undefined;
  }

  private async ensureConnected(): Promise<void> {
    if (this.websocket && this.websocket.readyState === WebSocket.OPEN && this.statusSnapshot.state === "connected") {
      return;
    }

    await this.connect();
  }

  private sendFrame(frame: RpcRequestFrame): void {
    if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
      throw new Error("Gateway websocket is not open.");
    }

    this.websocket.send(JSON.stringify(frame));
  }

  private createStatus(state: GatewayStatus["state"], message?: string): GatewayStatus {
    const url = new URL(this.url);
    return {
      state,
      url: this.url,
      host: url.hostname,
      port: Number(url.port || (url.protocol === "wss:" ? 443 : 80)),
      version: this.statusSnapshot?.version ?? "unknown",
      transport: "websocket",
      message,
      lastError: state === "error" ? message : undefined
    };
  }

  private appendLog(level: LogEntry["level"], message: string): void {
    this.logs.push({
      ts: new Date().toISOString(),
      level,
      source: "gateway",
      message: this.sanitizeLogMessage(message)
    });
  }

  private failConnection(message: string): void {
    for (const pending of this.pendingRequests.values()) {
      clearTimeout(pending.timer);
      pending.reject(new Error(message));
    }
    this.pendingRequests.clear();

    for (const [runId, pending] of this.pendingChatRuns.entries()) {
      clearTimeout(pending.timer);
      const error = new Error(message);
      pending.onError?.({ sessionId: pending.sessionKey, runId, error });
      pending.reject(error);
    }
    this.pendingChatRuns.clear();

    this.statusSnapshot = this.createStatus("error", message);
    this.appendLog("error", message);
  }

  private resetTransport(ws?: WebSocket): void {
    if (!ws || this.websocket === ws) {
      this.websocket = undefined;
    }
    this.handshakeSent = false;
    try {
      if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
        ws.close();
      }
    } catch {
      // Ignore transport cleanup errors while reporting the original connection failure.
    }
  }

  private flattenContent(content?: Array<{ type?: string; text?: string }>): string {
    if (!Array.isArray(content)) {
      return "";
    }
    return content
      .filter((part) => part?.type === "text" && typeof part.text === "string")
      .map((part) => part.text)
      .join("\n")
      .trim();
  }

  private parseGatewayLogLine(line: string): LogEntry | null {
    try {
      const parsed = JSON.parse(line) as {
        0?: string;
        1?: string;
        2?: string;
        msg?: string;
        message?: string;
        time?: string;
        _meta?: { logLevelName?: string };
      };
      return {
        ts: parsed.time ?? new Date().toISOString(),
        level: this.normalizeLogLevel(parsed._meta?.logLevelName),
        source: "gateway",
        message: this.sanitizeLogMessage(this.extractStructuredLogMessage(parsed, line))
      };
    } catch {
      return {
        ts: new Date().toISOString(),
        level: "info",
        source: "gateway",
        message: this.sanitizeLogMessage(line)
      };
    }
  }

  private extractStructuredLogMessage(
    parsed: {
      0?: string;
      1?: string;
      2?: string;
      msg?: string;
      message?: string;
    },
    fallback: string
  ): string {
    const candidates = [parsed.message, parsed.msg, parsed[2], parsed[1], parsed[0]];
    return candidates.find((candidate) => typeof candidate === "string" && candidate.trim()) ?? fallback;
  }

  private sanitizeLogMessage(message: string): string {
    const compact = message
      .replace(ANSI_ESCAPE_PATTERN, "")
      .replace(CONTROL_CHAR_PATTERN, "")
      .replace(/\s+/g, " ")
      .trim();

    const withoutPrefix = this.stripStructuredLogPrefix(compact);

    if (withoutPrefix.includes("event agent") && withoutPrefix.includes("stream=assistant")) {
      const runId = this.extractField(withoutPrefix, "run");
      const session = this.extractField(withoutPrefix, "session");
      return `assistant stream update${session ? ` session=${session}` : ""}${runId ? ` run=${runId}` : ""}`;
    }

    if (withoutPrefix.includes("event chat")) {
      return "chat event received";
    }

    if (withoutPrefix.includes("res") && withoutPrefix.includes("health")) {
      return "health response received";
    }

    return withoutPrefix;
  }

  private stripStructuredLogPrefix(message: string): string {
    return message
      .replace(LOG_PREFIX_PATTERN, "")
      .replace(/闂佽法鍠撶粊?/g, "ok ")
      .replace(/[?]{2,}/g, "")
      .replace(/\s+/g, " ")
      .trim();
  }

  private extractField(message: string, key: string): string | undefined {
    const match = message.match(new RegExp(`${key}=([^\\s]+)`));
    return match?.[1];
  }

  private normalizeLogLevel(level?: string): LogEntry["level"] {
    const upper = (level ?? "INFO").toUpperCase();
    if (upper.includes("ERROR")) {
      return "error";
    }
    if (upper.includes("WARN")) {
      return "warn";
    }
    return "info";
  }

  private formatGatewayError(error?: GatewayErrorShape): string {
    const code = error?.details?.code ?? error?.code;
    const message = error?.message ?? "Gateway request failed.";

    if (code === "DEVICE_IDENTITY_REQUIRED") {
      return "Gateway requires device pairing before this desktop app can connect.";
    }

    if (code === "AUTH_TOKEN_MISSING") {
      return "Gateway auth token is missing. Paste a gateway token in Settings and reconnect.";
    }

    if (code === "AUTH_TOKEN_MISMATCH") {
      return "Gateway token mismatch. Refresh the local gateway token and save it again.";
    }

    if (code === "CONTROL_UI_ORIGIN_NOT_ALLOWED") {
      return "Gateway rejected the request because this client is not an allowed Control UI origin.";
    }

    return message;
  }
}
