/**
 * Backend client — HTTP + bidirectional WebSocket communication with the
 * AdiuvAI backend API.
 *
 * Singleton. All AI intelligence lives on the backend; this client is the
 * Electron-side transport layer.
 *
 * Protocol:
 *  - Chat: WS `/api/v1/chat/stream?token=<jwt>`
 *    Client sends `chat_request` → Server streams `text_chunk` / `tool_call` /
 *    `final` / `ping` frames → Client handles `tool_call` via DrizzleExecutor
 *    and sends back `tool_result`.
 *
 *  - Embeddings: POST `/api/v1/chat/embed`
 *  - Health: GET `/api/v1/health`
 *
 * @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.3
 */

import { app } from 'electron';
import WebSocket from 'ws';
import { eq } from 'drizzle-orm';
import { getStore, getDeviceId, getLocalAgents, getFormatPrefs } from '../store';
import { detectFormatPrefs } from '../auth/locale-defaults';
import { getAuthManager } from '../auth/auth-manager';
import { toSnakeCase, toCamelCase } from '../../shared/casing';
import {
  WsServerFrameSchema,
} from '../../shared/api-types';
import type {
  WsToolResult,
  WsFloatingRequest,
  WsFloatingDomain,
} from '../../shared/api-types';
import { DrizzleExecutor } from './drizzle-executor';
import { getDb } from '../db';
import { agentRuns, agentRunActions } from '../db/schema';

// ---------------------------------------------------------------------------
// Agent run logging helpers
// ---------------------------------------------------------------------------

const TABLE_TO_ENTITY: Record<string, string> = {
  tasks: 'task',
  notes: 'note',
  projects: 'project',
  timeline_events: 'timeline',
  task_comments: 'comment',
};

function extractEntityTitle(data: Record<string, unknown> | undefined): string | null {
  if (!data) return null;
  return (data.title as string) ?? (data.name as string) ?? (data.content as string)?.slice(0, 60) ?? null;
}

async function recordRunAction(
  runId: string,
  agentId: string,
  verb: string,
  entityType: string,
  entityId: string | null,
  entityTitle: string | null,
): Promise<void> {
  try {
    await getDb().insert(agentRunActions).values({
      id: crypto.randomUUID(),
      runId,
      agentId,
      verb,
      entityType,
      entityId: entityId ?? null,
      entityTitle: entityTitle ?? null,
      createdAt: Date.now(),
    });
  } catch (err) {
    console.warn('[RunLog] Failed to record action:', err);
  }
}

// ---------------------------------------------------------------------------
// Dev-mode logger
// ---------------------------------------------------------------------------

const IS_DEV = process.env.NODE_ENV === 'development';

function truncateForLog(payload: unknown): string {
  const str = JSON.stringify(payload);
  return str.length > 500 ? `${str.slice(0, 500)}…` : str;
}

function logHttp(method: string, url: string, body?: unknown): void {
  if (!IS_DEV) return;
  const bodyStr = body !== undefined ? `\n  body: ${truncateForLog(body)}` : '';
  console.log(`[BE > HTTP] ${method} ${url}${bodyStr}`);
}

function logHttpResponse(method: string, url: string, status: number, body?: unknown): void {
  if (!IS_DEV) return;
  const bodyStr = body !== undefined ? `\n  response: ${truncateForLog(body)}` : '';
  console.log(`[BE < HTTP] ${status} ${method} ${url}${bodyStr}`);
}

function logWsSend(payload: unknown): void {
  if (!IS_DEV) return;
  console.log(`[BE > WS] ${truncateForLog(payload)}`);
}

function logWsRecv(payload: unknown): void {
  if (!IS_DEV) return;
  console.log(`[BE < WS] ${truncateForLog(payload)}`);
}

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const HEALTH_TIMEOUT_MS = 3_000;
const HTTP_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3;
/** Base delay for exponential backoff (ms). */
const RETRY_BASE_MS = 500;
/** Interval between client-side heartbeat pings on the persistent device WS. */
const HEARTBEAT_INTERVAL_MS = 30_000;
/** Time to wait for any response after a ping before treating the connection as dead. */
const PONG_TIMEOUT_MS = 10_000;
/** Base reconnect delay for the persistent device WS (doubles each attempt). */
const RECONNECT_BASE_MS = 1_000;
/** Maximum reconnect delay cap (ms). */
const RECONNECT_MAX_MS = 30_000;

// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------

export class OfflineError extends Error {
  constructor(message = 'Backend is unreachable') {
    super(message);
    this.name = 'OfflineError';
  }
}

export class AuthExpiredError extends Error {
  constructor(message = 'Authentication expired') {
    super(message);
    this.name = 'AuthExpiredError';
  }
}

export class RateLimitError extends Error {
  constructor(message = 'Rate limit exceeded') {
    super(message);
    this.name = 'RateLimitError';
  }
}

export class ServerError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number,
  ) {
    super(message);
    this.name = 'ServerError';
  }
}

export class QuotaError extends Error {
  constructor(
    public readonly reason: 'max_files' | 'monthly_tokens',
    message: string,
  ) {
    super(message);
    this.name = 'QuotaError';
  }
}

// ---------------------------------------------------------------------------
// V3 stream listener types
// ---------------------------------------------------------------------------

interface StreamListener {
  onStart: () => void;
  onText: (chunk: string) => void;
  onEnd: (mutations?: unknown) => void;
  onDomain: (domain: WsFloatingDomain['domain']) => void;
  onError: (err: Error) => void;
  resolve: () => void;
  reject: (err: Error) => void;
}

/** Pending journey reply listener — resolves when a `journey_reply` arrives. */
interface JourneyListener {
  resolve: (reply: { sessionId: string; message: string; done: boolean; agentConfig?: string | null }) => void;
  reject: (err: Error) => void;
}

export interface IndexSessionListener {
  onFileResult: (frame: { relPath: string; summary: string | null; tokensUsed: number; error?: string }) => void;
  onProgress: (frame: { processed: number; total: number }) => void;
  onDone: (status: 'completed' | 'cancelled' | 'quota_exceeded' | 'error') => void;
}

// ---------------------------------------------------------------------------
// BackendClient
// ---------------------------------------------------------------------------

export class BackendClient {
  private static instance: BackendClient | null = null;
  private executor = new DrizzleExecutor();

  // Persistent device WebSocket state
  private persistentWs: WebSocket | null = null;
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
  private pongTimer: ReturnType<typeof setTimeout> | null = null;
  private isConnecting = false;
  private shouldReconnect = false;
  private reconnectAttempt = 0;

  /** V3 stream listeners keyed by requestId. */
  private streamListeners: Map<string, StreamListener> = new Map();

  /** Journey reply listeners keyed by sessionId. */
  private journeyListeners: Map<string, JourneyListener> = new Map();

  /** Index session listeners keyed by sessionId. */
  private indexListeners: Map<string, IndexSessionListener> = new Map();

  // eslint-disable-next-line @typescript-eslint/no-empty-function
  private constructor() {}

  static getInstance(): BackendClient {
    if (!this.instance) {
      this.instance = new BackendClient();
    }
    return this.instance;
  }

  // -------------------------------------------------------------------------
  // URL helpers
  // -------------------------------------------------------------------------

  private get baseUrl(): string {
    return getStore().get('backendUrl');
  }

  /** Convert http(s):// base URL to ws(s):// for WebSocket connections. */
  private get wsBaseUrl(): string {
    return this.baseUrl.replace(/^http/, 'ws');
  }

  // -------------------------------------------------------------------------
  // Health check
  // -------------------------------------------------------------------------

  /** Returns `true` if the backend responds within 3 s, `false` otherwise. */
  async isOnline(): Promise<boolean> {
    try {
      logHttp('GET', `${this.baseUrl}/api/v1/health`);
      const res = await fetch(`${this.baseUrl}/api/v1/health`, {
        signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
      });
      logHttpResponse('GET', `${this.baseUrl}/api/v1/health`, res.status);
      return res.ok;
    } catch {
      return false;
    }
  }

  // -------------------------------------------------------------------------
  // V3 Chat — send via persistent device WS
  // -------------------------------------------------------------------------

  /**
   * Send a home chat request over the persistent device WS.
   * Registers a stream listener keyed by `requestId` and returns a promise
   * that resolves when `stream_end` arrives.
   */
  sendHomeRequest(
    message: string,
    conversationHistory?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
    requestId?: string,
    sessionId?: string,
    callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
  ): { requestId: string; promise: Promise<void> } {
    const activeRequestId = requestId ?? crypto.randomUUID();
    const promise = new Promise<void>((resolve, reject) => {
      this.streamListeners.set(activeRequestId, {
        onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
        onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
        onEnd: (mutations) => {
          callbacks?.onEnd?.(mutations);
          this.streamListeners.delete(activeRequestId);
          resolve();
        },
        onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
        onError: (err) => {
          callbacks?.onError?.(err);
          this.streamListeners.delete(activeRequestId);
          reject(err);
        },
        resolve,
        reject,
      });

      const ws = this.persistentWs;
      if (!ws || ws.readyState !== WebSocket.OPEN) {
        this.streamListeners.delete(activeRequestId);
        reject(new OfflineError('Persistent WS not connected'));
        return;
      }

      const rawPrefs = getFormatPrefs() ?? detectFormatPrefs();
      const homePayload = toSnakeCase({
        type: 'home_request',
        requestId: activeRequestId,
        sessionId,
        message,
        conversationHistory,
        formatPrefs: {
          timezone: rawPrefs.timezone,
          dateFormat: rawPrefs.dateFormat,
          timeFormat: rawPrefs.timeFormat,
          locale: app.getLocale(),
          nowIso: new Date().toISOString(),
        },
      });
      logWsSend(homePayload);
      ws.send(JSON.stringify(homePayload));
    });

    return { requestId: activeRequestId, promise };
  }

  /**
   * Send a brief request over the persistent device WS.
   * Same listener pattern as `sendHomeRequest` but uses the `brief_request` frame.
   */
  sendBriefRequest(
    mode: 'home' | 'project',
    projectId?: string,
    requestId?: string,
    sessionId?: string,
    callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
  ): { requestId: string; promise: Promise<void> } {
    const activeRequestId = requestId ?? crypto.randomUUID();
    const promise = new Promise<void>((resolve, reject) => {
      this.streamListeners.set(activeRequestId, {
        onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
        onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
        onEnd: (mutations) => {
          callbacks?.onEnd?.(mutations);
          this.streamListeners.delete(activeRequestId);
          resolve();
        },
        onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
        onError: (err) => {
          callbacks?.onError?.(err);
          this.streamListeners.delete(activeRequestId);
          reject(err);
        },
        resolve,
        reject,
      });

      const ws = this.persistentWs;
      if (!ws || ws.readyState !== WebSocket.OPEN) {
        this.streamListeners.delete(activeRequestId);
        reject(new OfflineError('Persistent WS not connected'));
        return;
      }

      const rawPrefs = getFormatPrefs() ?? detectFormatPrefs();
      const briefPayload = toSnakeCase({
        type: 'brief_request',
        requestId: activeRequestId,
        sessionId,
        mode,
        projectId,
        formatPrefs: {
          timezone: rawPrefs.timezone,
          dateFormat: rawPrefs.dateFormat,
          timeFormat: rawPrefs.timeFormat,
          locale: app.getLocale(),
          nowIso: new Date().toISOString(),
        },
      });
      logWsSend(briefPayload);
      ws.send(JSON.stringify(briefPayload));
    });

    return { requestId: activeRequestId, promise };
  }

  /**
   * Send a floating chat request over the persistent device WS.
   * Same listener pattern as `sendHomeRequest`.
   */
  sendFloatingRequest(
    message: string,
    scope: WsFloatingRequest['scope'],
    conversationHistory?: WsFloatingRequest['conversationHistory'],
    requestId?: string,
    sessionId?: string,
    briefMode?: boolean,
    briefingContext?: string,
    callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
  ): { requestId: string; promise: Promise<void> } {
    const activeRequestId = requestId ?? crypto.randomUUID();
    const promise = new Promise<void>((resolve, reject) => {
      this.streamListeners.set(activeRequestId, {
        onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
        onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
        onEnd: (mutations) => {
          callbacks?.onEnd?.(mutations);
          this.streamListeners.delete(activeRequestId);
          resolve();
        },
        onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
        onError: (err) => {
          callbacks?.onError?.(err);
          this.streamListeners.delete(activeRequestId);
          reject(err);
        },
        resolve,
        reject,
      });

      const ws = this.persistentWs;
      if (!ws || ws.readyState !== WebSocket.OPEN) {
        this.streamListeners.delete(activeRequestId);
        reject(new OfflineError('Persistent WS not connected'));
        return;
      }

      const rawPrefs = getFormatPrefs() ?? detectFormatPrefs();
      const floatingPayload = toSnakeCase({
        type: 'floating_request',
        requestId: activeRequestId,
        sessionId,
        message,
        scope,
        conversationHistory,
        briefMode: briefMode ?? false,
        briefingContext: briefingContext ?? null,
        formatPrefs: {
          timezone: rawPrefs.timezone,
          dateFormat: rawPrefs.dateFormat,
          timeFormat: rawPrefs.timeFormat,
          locale: app.getLocale(),
          nowIso: new Date().toISOString(),
        },
      });
      logWsSend(floatingPayload);
      ws.send(JSON.stringify(floatingPayload));
    });

    return { requestId: activeRequestId, promise };
  }

  /**
   * Send a task brief research request over the persistent device WS.
   * Backend runs a deep-research Stage 1 agent and streams the briefing.
   */
  sendTaskBriefRequest(
    taskId: string,
    requestId?: string,
    sessionId?: string,
    callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
  ): { requestId: string; promise: Promise<void> } {
    const activeRequestId = requestId ?? crypto.randomUUID();
    const promise = new Promise<void>((resolve, reject) => {
      this.streamListeners.set(activeRequestId, {
        onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
        onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
        onEnd: (mutations) => {
          callbacks?.onEnd?.(mutations);
          this.streamListeners.delete(activeRequestId);
          resolve();
        },
        onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
        onError: (err) => {
          callbacks?.onError?.(err);
          this.streamListeners.delete(activeRequestId);
          reject(err);
        },
        resolve,
        reject,
      });

      const ws = this.persistentWs;
      if (!ws || ws.readyState !== WebSocket.OPEN) {
        this.streamListeners.delete(activeRequestId);
        reject(new OfflineError('Persistent WS not connected'));
        return;
      }

      const rawPrefs = getFormatPrefs() ?? detectFormatPrefs();
      const payload = toSnakeCase({
        type: 'task_brief_request',
        requestId: activeRequestId,
        sessionId,
        taskId,
        formatPrefs: {
          timezone: rawPrefs.timezone,
          dateFormat: rawPrefs.dateFormat,
          timeFormat: rawPrefs.timeFormat,
          locale: app.getLocale(),
          nowIso: new Date().toISOString(),
        },
      });
      logWsSend(payload);
      ws.send(JSON.stringify(payload));
    });

    return { requestId: activeRequestId, promise };
  }

  // -------------------------------------------------------------------------
  // Journey — setup agent prompt via WS
  // -------------------------------------------------------------------------

  /**
   * Start a journey session for custom prompt creation.
   * Sends `journey_start` over the persistent WS and awaits the first
   * `journey_reply` frame from the server.
   */
  sendJourneyStart(
    sessionId: string,
    agentType: 'local_directory' | 'gmail' | 'teams' | 'outlook',
    dataTypes: string[],
    directory?: string,
    existingConfig?: string | null,
  ): Promise<{ sessionId: string; message: string; done: boolean; agentConfig?: string | null }> {
    return new Promise((resolve, reject) => {
      this.journeyListeners.set(sessionId, { resolve, reject });

      const ws = this.persistentWs;
      if (!ws || ws.readyState !== WebSocket.OPEN) {
        this.journeyListeners.delete(sessionId);
        reject(new OfflineError('Persistent WS not connected'));
        return;
      }

      const payload = toSnakeCase({
        type: 'journey_start',
        sessionId,
        agentType,
        directory: directory ?? null,
        dataTypes,
        existingConfig: existingConfig ?? null,
      });
      logWsSend(payload);
      ws.send(JSON.stringify(payload));
    });
  }

  /**
   * Send a user message in an active journey session.
   * Sends `journey_message` over the persistent WS and awaits the next
   * `journey_reply` frame.
   */
  sendJourneyMessage(
    sessionId: string,
    message: string,
  ): Promise<{ sessionId: string; message: string; done: boolean; agentConfig?: string | null }> {
    return new Promise((resolve, reject) => {
      this.journeyListeners.set(sessionId, { resolve, reject });

      const ws = this.persistentWs;
      if (!ws || ws.readyState !== WebSocket.OPEN) {
        this.journeyListeners.delete(sessionId);
        reject(new OfflineError('Persistent WS not connected'));
        return;
      }

      const payload = toSnakeCase({
        type: 'journey_message',
        sessionId,
        message,
      });
      logWsSend(payload);
      ws.send(JSON.stringify(payload));
    });
  }

  // -------------------------------------------------------------------------
  // Index session WS methods
  // -------------------------------------------------------------------------

  /**
   * Register a listener for an index session. Must be called before sending
   * the first `index_session_start` for that sessionId. Listener is auto-
   * removed when `index_session_done` is received.
   */
  registerIndexSession(sessionId: string, listener: IndexSessionListener): void {
    this.indexListeners.set(sessionId, listener);
  }

  /**
   * Send the opening `index_session_start` frame. Must be called after
   * `registerIndexSession`. Throws OfflineError if the WS is not connected.
   */
  sendIndexSessionStart(sessionId: string, projectId: string, totalFiles: number): void {
    const ws = this.persistentWs;
    if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
    const payload = toSnakeCase({ type: 'index_session_start', sessionId, projectId, totalFiles });
    logWsSend(payload);
    ws.send(JSON.stringify(payload));
  }

  /**
   * Send one batch of files (typically up to 5).
   */
  sendIndexFileBatch(
    sessionId: string,
    files: Array<{
      relPath: string;
      kind: 'text' | 'image' | 'pdf' | 'docx';
      content: string;
      ext?: string;
      mime?: string;
      sizeBytes: number;
      mtimeMs: number;
    }>,
  ): void {
    const ws = this.persistentWs;
    if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
    const payload = toSnakeCase({ type: 'index_file_batch', sessionId, files });
    logWsSend(payload);
    ws.send(JSON.stringify(payload));
  }

  /**
   * Cancel an in-flight index session.
   */
  sendIndexSessionCancel(sessionId: string): void {
    const ws = this.persistentWs;
    if (!ws || ws.readyState !== WebSocket.OPEN) return; // best-effort
    const payload = toSnakeCase({ type: 'index_session_cancel', sessionId });
    logWsSend(payload);
    ws.send(JSON.stringify(payload));
  }

  // -------------------------------------------------------------------------
  // HTTP utilities
  // -------------------------------------------------------------------------

  private async assertHttpOk(res: Response): Promise<void> {
    if (res.ok) return;
    const body = await res.text().catch(() => '');
    const msg = `${res.status} ${res.statusText}${body ? `: ${body}` : ''}`;
    if (res.status === 401) throw new AuthExpiredError(msg);
    if (res.status === 429) throw new RateLimitError(msg);
    if (res.status >= 500) throw new ServerError(msg, res.status);
    throw new Error(msg);
  }

  // -------------------------------------------------------------------------
  // Generic authenticated HTTP proxies (Phase 3, Step 3.4)
  // -------------------------------------------------------------------------

  /** Authenticated GET — response is camelCased. */
  async proxyGet<T>(path: string): Promise<T> {
    return this.withRetry(
      async () => {
        const token = await getAuthManager().getAccessToken();
        if (!token) throw new AuthExpiredError();

        logHttp('GET', `${this.baseUrl}${path}`);
        const res = await fetch(`${this.baseUrl}${path}`, {
          method: 'GET',
          headers: { Authorization: `Bearer ${token}` },
          signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
        });

        logHttpResponse('GET', `${this.baseUrl}${path}`, res.status);
        await this.assertHttpOk(res);
        return toCamelCase<T>(await res.json());
      },
      (err) => !(err instanceof AuthExpiredError),
    );
  }

  /** Authenticated POST — body is snake_cased, response is camelCased. */
  async proxyPost<T>(path: string, body: Record<string, unknown>): Promise<T> {
    return this.withRetry(
      async () => {
        const token = await getAuthManager().getAccessToken();
        if (!token) throw new AuthExpiredError();

        logHttp('POST', `${this.baseUrl}${path}`, body);
        const res = await fetch(`${this.baseUrl}${path}`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${token}`,
          },
          body: JSON.stringify(toSnakeCase(body)),
          signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
        });

        logHttpResponse('POST', `${this.baseUrl}${path}`, res.status);
        await this.assertHttpOk(res);
        return toCamelCase<T>(await res.json());
      },
      (err) => !(err instanceof AuthExpiredError),
    );
  }

  /** Authenticated PUT — body is snake_cased, response is camelCased. */
  async proxyPut<T>(path: string, body: Record<string, unknown>): Promise<T> {
    return this.withRetry(
      async () => {
        const token = await getAuthManager().getAccessToken();
        if (!token) throw new AuthExpiredError();

        logHttp('PUT', `${this.baseUrl}${path}`, body);
        const res = await fetch(`${this.baseUrl}${path}`, {
          method: 'PUT',
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${token}`,
          },
          body: JSON.stringify(toSnakeCase(body)),
          signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
        });

        logHttpResponse('PUT', `${this.baseUrl}${path}`, res.status);
        await this.assertHttpOk(res);
        return toCamelCase<T>(await res.json());
      },
      (err) => !(err instanceof AuthExpiredError),
    );
  }

  /** Authenticated DELETE — response is camelCased. */
  async proxyDelete<T>(path: string): Promise<T> {
    return this.withRetry(
      async () => {
        const token = await getAuthManager().getAccessToken();
        if (!token) throw new AuthExpiredError();

        logHttp('DELETE', `${this.baseUrl}${path}`);
        const res = await fetch(`${this.baseUrl}${path}`, {
          method: 'DELETE',
          headers: { Authorization: `Bearer ${token}` },
          signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
        });

        logHttpResponse('DELETE', `${this.baseUrl}${path}`, res.status);
        await this.assertHttpOk(res);
        return toCamelCase<T>(await res.json());
      },
      (err) => !(err instanceof AuthExpiredError),
    );
  }

  // -------------------------------------------------------------------------
  // Billing quota pre-flight
  // -------------------------------------------------------------------------

  /**
   * Pre-flight quota check for folder indexing.
   *
   * Calls `POST /api/v1/billing/quota/check` with `{ feature: "folder_index",
   * estimated_files: estimatedFiles }`.
   *
   * Returns `{ ok: true }` when the backend allows the operation.
   * Throws `QuotaError` when the backend responds with HTTP 402.
   * Propagates `AuthExpiredError` / `OfflineError` on auth / network failure.
   */
  async checkFolderQuota(estimatedFiles: number): Promise<{ ok: true }> {
    const token = await getAuthManager().getAccessToken();
    if (!token) throw new AuthExpiredError();

    const url = `${this.baseUrl}/api/v1/billing/quota/check`;
    const bodyPayload = { feature: 'folder_index', estimated_files: estimatedFiles };
    logHttp('POST', url, bodyPayload);

    let res: Response;
    try {
      res = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify(bodyPayload),
        signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
      });
    } catch (err) {
      throw new OfflineError(err instanceof Error ? err.message : 'Network error');
    }

    logHttpResponse('POST', url, res.status);

    if (res.ok) return { ok: true };

    if (res.status === 402) {
      let detail: { reason: 'max_files' | 'monthly_tokens'; message: string } | null = null;
      try {
        const body = await res.json() as { detail?: { reason: string; message: string } };
        if (body.detail?.reason && body.detail?.message) {
          detail = {
            reason: body.detail.reason as 'max_files' | 'monthly_tokens',
            message: body.detail.message,
          };
        }
      } catch { /* ignore parse errors */ }
      const reason = detail?.reason ?? 'max_files';
      const message = detail?.message ?? 'Quota exceeded';
      throw new QuotaError(reason, message);
    }

    // Other error codes
    const text = await res.text().catch(() => '');
    const msg = `${res.status} ${res.statusText}${text ? `: ${text}` : ''}`;
    if (res.status === 401) throw new AuthExpiredError(msg);
    if (res.status === 429) throw new RateLimitError(msg);
    if (res.status >= 500) throw new ServerError(msg, res.status);
    throw new Error(msg);
  }

  // -------------------------------------------------------------------------

  /**
   * Opens and maintains a persistent WebSocket connection to the backend
   * device endpoint (`/api/v1/ws/device`).  On connect, sends a
   * `device_hello` frame identifying this machine and its active local
   * agents.  Handles `tool_call` frames; auto-reconnects
   * with exponential backoff; sends WS-level heartbeat pings every 30 s.
   *
   * Safe to call multiple times — a no-op if already connected or
   * connecting.  Call once after confirming authentication on app startup,
   * and again after a successful login.
   */
  async connectPersistent(): Promise<void> {
    if (this.isConnecting || this.persistentWs?.readyState === WebSocket.OPEN) {
      return;
    }
    this.shouldReconnect = true;
    await this.openDeviceWebSocket();
  }

  /**
   * Gracefully closes the persistent device WebSocket and disables
   * auto-reconnect.  Call on logout or app quit.
   */
  disconnectPersistent(): void {
    this.shouldReconnect = false;
    this.reconnectAttempt = 0;
    this.stopHeartbeat();
    this.clearReconnectTimer();
    if (this.persistentWs) {
      try { this.persistentWs.close(); } catch { /* ignore */ }
      this.persistentWs = null;
    }
    console.log('[DeviceWS] Disconnected.');
  }

  private async openDeviceWebSocket(): Promise<void> {
    if (this.isConnecting) return;
    this.isConnecting = true;

    let token: string | null;
    try {
      token = await getAuthManager().getAccessToken();
      if (!token) throw new AuthExpiredError();
    } catch (err) {
      this.isConnecting = false;
      if (err instanceof AuthExpiredError) {
        console.warn('[DeviceWS] No auth token — skipping persistent connection.');
        this.shouldReconnect = false;
        return;
      }
      this.scheduleReconnect();
      return;
    }

    const wsUrl = `${this.wsBaseUrl}/api/v1/ws/device?token=${encodeURIComponent(token)}`;
    console.log('[DeviceWS] Connecting…');
    const ws = new WebSocket(wsUrl);
    this.persistentWs = ws;

    ws.on('open', async () => {
      this.isConnecting = false;
      this.reconnectAttempt = 0;
      console.log('[DeviceWS] Connected.');

      // Read enabled local agent IDs from local storage
      const deviceId = getDeviceId();
      const agentIds = getLocalAgents()
        .filter((a) => a.enabled)
        .map((a) => a.id);

      ws.send(JSON.stringify(toSnakeCase({ type: 'device_hello', deviceId, agentIds })));
      console.log(`[DeviceWS] Sent device_hello (deviceId=${deviceId}, agents=${agentIds.length}).`);
      this.startHeartbeat(ws);
    });

    ws.on('message', (raw: Buffer | string) => {
      const text = typeof raw === 'string' ? raw : raw.toString('utf8');
      let parsed: unknown;
      try {
        parsed = JSON.parse(text);
      } catch {
        return;
      }

      logWsRecv(parsed);
      const frame = WsServerFrameSchema.safeParse(toCamelCase(parsed));
      if (!frame.success) return;

      // Any incoming frame resets the pong timeout
      this.clearPongTimer();

      switch (frame.data.type) {
        case 'tool_call': {
          const toolCall = frame.data;
          void (async () => {
            let result: WsToolResult;
            try {
              const output = await this.executor.execute(toolCall);
              result = { type: 'tool_result', id: toolCall.id, ...output } as WsToolResult;
            } catch (err) {
              const msg = err instanceof Error ? err.message : 'Executor error';
              result = { type: 'tool_result', id: toolCall.id, error: msg };
            }
            if (ws.readyState === WebSocket.OPEN) {
              logWsSend(result);
              ws.send(JSON.stringify(toSnakeCase(result)));
            }

            // Log mutating actions from batch agent runs
            const rc = toolCall.runContext;
            if (rc && ['insert', 'update', 'delete'].includes(toolCall.action)) {
              const entityType = TABLE_TO_ENTITY[toolCall.table ?? ''];
              if (entityType) {
                const verb = toolCall.action === 'insert' ? 'created'
                  : toolCall.action === 'update' ? 'updated' : 'deleted';
                const entityId = ('row' in result && result.row?.id as string) ?? null;
                const entityTitle = extractEntityTitle(toolCall.data);
                await recordRunAction(rc.runId, rc.agentId, verb, entityType, entityId, entityTitle);
              }
            }
          })();
          break;
        }

        case 'ping':
          // Server keep-alive — no-op
          break;

        // V3 streaming frames — dispatch to registered listener
        case 'stream_start': {
          const listener = this.streamListeners.get(frame.data.requestId);
          listener?.onStart();
          break;
        }

        case 'stream_text': {
          const listener = this.streamListeners.get(frame.data.requestId);
          listener?.onText(frame.data.chunk);
          break;
        }

        case 'stream_end': {
          const listener = this.streamListeners.get(frame.data.requestId);
          listener?.onEnd(frame.data.mutations as unknown);
          break;
        }

        case 'floating_domain': {
          const listener = this.streamListeners.get(frame.data.requestId);
          listener?.onDomain(frame.data.domain);
          break;
        }

        case 'run_complete': {
          const { runContext, status } = frame.data;
          void (async () => {
            try {
              const db = getDb();
              await db.update(agentRuns)
                .set({ status: status === 'success' ? 'completed' : status === 'partial' ? 'partial' : 'failed', completedAt: Date.now() })
                .where(eq(agentRuns.id, runContext.runId));
            } catch (err) {
              console.warn('[RunLog] Failed to close run:', err);
            }
          })();
          break;
        }

        case 'journey_reply': {
          const jl = this.journeyListeners.get(frame.data.sessionId);
          if (jl) {
            this.journeyListeners.delete(frame.data.sessionId);
            jl.resolve({
              sessionId: frame.data.sessionId,
              message: frame.data.message,
              done: frame.data.done,
              agentConfig: frame.data.agentConfig,
            });
          }
          break;
        }

        case 'index_file_result': {
          const lis = this.indexListeners.get(frame.data.sessionId);
          lis?.onFileResult({
            relPath: frame.data.relPath,
            summary: frame.data.summary ?? null,
            tokensUsed: frame.data.tokensUsed,
            error: frame.data.error,
          });
          break;
        }

        case 'index_session_progress': {
          const lis = this.indexListeners.get(frame.data.sessionId);
          lis?.onProgress({ processed: frame.data.processed, total: frame.data.total });
          break;
        }

        case 'index_session_done': {
          const lis = this.indexListeners.get(frame.data.sessionId);
          if (lis) {
            lis.onDone(frame.data.status);
            this.indexListeners.delete(frame.data.sessionId);
          }
          break;
        }
      }
    });

    ws.on('pong', () => {
      // WS-level pong — reset the timeout
      this.clearPongTimer();
    });

    ws.on('error', (err: Error) => {
      this.isConnecting = false;
      console.error('[DeviceWS] Error:', err.message);
      this.stopHeartbeat();
    });

    ws.on('close', (code: number) => {
      this.isConnecting = false;
      this.persistentWs = null;
      this.stopHeartbeat();
      console.log(`[DeviceWS] Connection closed (code ${code}).`);

      // Reject any in-flight stream listeners so callers don't hang forever.
      const closeErr = new Error('WebSocket connection closed');
      if (this.streamListeners.size > 0) {
        for (const listener of this.streamListeners.values()) {
          try { listener.onError(closeErr); } catch { /* ignore */ }
        }
        this.streamListeners.clear();
      }
      if (this.journeyListeners.size > 0) {
        for (const jl of this.journeyListeners.values()) {
          try { jl.reject(closeErr); } catch { /* ignore */ }
        }
        this.journeyListeners.clear();
      }
      // Index session listeners are fire-and-forget; just drop them on disconnect.
      this.indexListeners.clear();

      if (this.shouldReconnect) {
        this.scheduleReconnect();
      }
    });
  }

  private startHeartbeat(ws: WebSocket): void {
    this.stopHeartbeat();
    this.heartbeatTimer = setInterval(() => {
      if (ws.readyState !== WebSocket.OPEN) {
        this.stopHeartbeat();
        return;
      }
      ws.ping();
      // Arm a timeout — if no pong/message within PONG_TIMEOUT_MS, force reconnect
      this.pongTimer = setTimeout(() => {
        console.warn('[DeviceWS] Pong timeout — forcing reconnect.');
        try { ws.terminate(); } catch { /* ignore */ }
      }, PONG_TIMEOUT_MS);
    }, HEARTBEAT_INTERVAL_MS);
  }

  private stopHeartbeat(): void {
    if (this.heartbeatTimer !== null) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
    this.clearPongTimer();
  }

  private clearPongTimer(): void {
    if (this.pongTimer !== null) {
      clearTimeout(this.pongTimer);
      this.pongTimer = null;
    }
  }

  private scheduleReconnect(): void {
    this.clearReconnectTimer();
    const delay = Math.min(
      RECONNECT_BASE_MS * 2 ** this.reconnectAttempt,
      RECONNECT_MAX_MS,
    );
    this.reconnectAttempt += 1;
    console.log(`[DeviceWS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})…`);
    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null;
      void this.openDeviceWebSocket();
    }, delay);
  }

  private clearReconnectTimer(): void {
    if (this.reconnectTimer !== null) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }
  }

  // -------------------------------------------------------------------------
  // Retry
  // -------------------------------------------------------------------------

  private async withRetry<T>(
    fn: () => Promise<T>,
    shouldRetry: (err: unknown) => boolean = () => true,
  ): Promise<T> {
    let lastErr: unknown;
    for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
      try {
        return await fn();
      } catch (err) {
        lastErr = err;
        if (!shouldRetry(err)) break;
        if (attempt < MAX_RETRIES - 1) {
          const delay = RETRY_BASE_MS * 2 ** attempt;
          await new Promise((r) => setTimeout(r, delay));
        }
      }
    }
    throw lastErr;
  }
}

// ---------------------------------------------------------------------------
// Convenience singleton accessor
// ---------------------------------------------------------------------------

export function getBackendClient(): BackendClient {
  return BackendClient.getInstance();
}
