// Thin Fabric REST client. One auth concept: an agent's Center API key is
// exchanged for a normal user session (POST /auth/agent/login); the returned
// guild access tokens are used to post messages and call channel APIs.

export type FabricSession = {
  accessToken: string;
  refreshToken: string;
  user: { id: string; email: string; name: string };
  guilds: Array<{ nodeId: string; name: string; endpoint: string; status: string }>;
  guildAccessTokens: Array<{ guildNodeId: string; token: string }>;
};

export class FabricClient {
  constructor(private readonly centerApiBase: string) {}

  private async post<T>(url: string, body: unknown, auth?: string): Promise<T> {
    const res = await fetch(url, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        ...(auth ? { authorization: `Bearer ${auth}` } : {}),
      },
      body: JSON.stringify(body),
    });
    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new Error(`POST ${url} -> ${res.status} ${text}`);
    }
    return (await res.json()) as T;
  }

  // Generic JSON request (GET/PUT/PATCH/DELETE). Empty 2xx body -> null
  // (Fabric returns an empty body when a channel has no canvas).
  private async req<T>(
    method: string,
    url: string,
    auth?: string,
    body?: unknown,
    extraHeaders?: Record<string, string>,
  ): Promise<T> {
    const res = await fetch(url, {
      method,
      headers: {
        ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
        ...(auth ? { authorization: `Bearer ${auth}` } : {}),
        ...(extraHeaders ?? {}),
      },
      body: body !== undefined ? JSON.stringify(body) : undefined,
    });
    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new Error(`${method} ${url} -> ${res.status} ${text}`);
    }
    const text = await res.text();
    return (text ? JSON.parse(text) : null) as T;
  }

  // Exchange an agent API key for a Fabric user session (+ guild tokens).
  agentLogin(apiKey: string): Promise<FabricSession> {
    return this.post<FabricSession>(`${this.centerApiBase}/auth/agent/login`, { apiKey });
  }

  // Refresh the center access token (guild tokens are re-fetched via /auth/me/guilds).
  async refresh(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
    return this.post(`${this.centerApiBase}/auth/refresh`, { refreshToken });
  }

  async meGuilds(accessToken: string): Promise<Pick<FabricSession, 'guilds' | 'guildAccessTokens'>> {
    const res = await fetch(`${this.centerApiBase}/auth/me/guilds`, {
      headers: { authorization: `Bearer ${accessToken}` },
    });
    if (!res.ok) throw new Error(`me/guilds -> ${res.status}`);
    return (await res.json()) as Pick<FabricSession, 'guilds' | 'guildAccessTokens'>;
  }

  // ---- guild-scoped (use the per-guild access token) ----

  postMessage(
    guildEndpoint: string,
    guildToken: string,
    channelId: string,
    content: string,
    authorUserId: string,
  ): Promise<unknown> {
    return this.post(
      `${guildEndpoint}/api/channels/${channelId}/messages`,
      { content, authorUserId },
      guildToken,
    );
  }

  createChannel(
    guildEndpoint: string,
    guildToken: string,
    body: {
      guildId: string;
      name: string;
      xType: string;
      isPublic?: boolean;
      memberUserIds?: string[];
      onDuty?: string;
      listeners?: string[];
    },
  ): Promise<{ id: string }> {
    return this.post(`${guildEndpoint}/api/channels`, body, guildToken);
  }

  closeChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
    return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken);
  }

  joinChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
    return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
  }

  leaveChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
    return this.post(`${guildEndpoint}/api/channels/${channelId}/leave`, {}, guildToken);
  }

  // Register the OpenClaw slash-command catalog with this guild (idempotent
  // full replace). The frontend GETs it for `/` autocomplete; execution
  // still flows as a normal /<cmd> message into OpenClaw's command system.
  syncCommands(
    guildEndpoint: string,
    guildToken: string,
    commands: unknown[],
    syncKey: string,
  ): Promise<unknown> {
    // Guild C-2: the shared key is sourced from the channel config
    // (channels.fabric.commandsSyncKey) and must equal the guild's
    // FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY for the catalog write.
    return this.req(
      'PUT',
      `${guildEndpoint}/api/commands`,
      guildToken,
      { commands },
      syncKey ? { 'x-commands-sync-key': syncKey } : undefined,
    );
  }

  // [{ userId, bypass }] — bypass is true only for discuss/work bypass-list
  channelMembers(
    guildEndpoint: string,
    guildToken: string,
    channelId: string,
  ): Promise<Array<{ userId: string; bypass?: boolean }>> {
    return this.req(
      'GET',
      `${guildEndpoint}/api/channels/${channelId}/members`,
      guildToken,
    );
  }

  // ---- channel canvas (one pinned doc per channel) ----

  private canvasUrl(endpoint: string, channelId: string): string {
    return `${endpoint}/api/channels/${channelId}/canvas`;
  }

  // null when the channel has no canvas
  getCanvas(
    endpoint: string,
    token: string,
    channelId: string,
  ): Promise<FabricCanvas | null> {
    return this.req('GET', this.canvasUrl(endpoint, channelId), token);
  }

  // share / replace (caller becomes the sharer)
  shareCanvas(
    endpoint: string,
    token: string,
    channelId: string,
    body: CanvasInput,
  ): Promise<FabricCanvas> {
    return this.req('PUT', this.canvasUrl(endpoint, channelId), token, body);
  }

  // update in place (original sharer only — else the guild returns 403)
  updateCanvas(
    endpoint: string,
    token: string,
    channelId: string,
    body: Partial<CanvasInput>,
  ): Promise<FabricCanvas> {
    return this.req('PATCH', this.canvasUrl(endpoint, channelId), token, body);
  }

  // remove ("close") the canvas (original sharer only)
  removeCanvas(endpoint: string, token: string, channelId: string): Promise<unknown> {
    return this.req('DELETE', this.canvasUrl(endpoint, channelId), token);
  }

  // ---- channel discovery + message read (used by the agent-facing
  // fabric-channel-list / fabric-message-history tools) ----

  /**
   * List channels in a guild visible to the calling user. Backend
   * filters to public + channels the user is a member of.
   */
  listChannels(
    guildEndpoint: string,
    guildToken: string,
    guildNodeId: string,
  ): Promise<Array<{
    id: string;
    guildId: string;
    name: string;
    xType: string;
    kind: string;
    isPublic: boolean;
    closed: boolean;
    lastSeq: number;
    createdAt: string;
  }>> {
    return this.req(
      'GET',
      `${guildEndpoint}/api/channels?guildId=${encodeURIComponent(guildNodeId)}`,
      guildToken,
    );
  }

  /**
   * Page through a channel's message history by `seq`.
   *
   * Backend defaults: 50 / call, max 200. The `seq` field starts at 1
   * per channel; pass `seqFrom=channel.lastSeq - N + 1` to get the
   * tail. Page metadata in the response describes what to ask next.
   */
  listMessages(
    guildEndpoint: string,
    guildToken: string,
    channelId: string,
    opts: { seqFrom?: number; seqTo?: number; limit?: number } = {},
  ): Promise<{
    items: Array<{
      messageId: string;
      seq: number;
      content: string;
      authorUserId: string;
      createdAt: string;
      editedAt: string | null;
      deletedAt: string | null;
      isDeleted: boolean;
    }>;
    page: {
      seqFrom: number;
      seqTo: number;
      limit: number;
      returned: number;
      hasMore: boolean;
      nextExpectedSeq: number;
      highestCommittedSeq: number;
    };
  }> {
    const qs = new URLSearchParams();
    if (opts.seqFrom !== undefined) qs.set('seq_from', String(opts.seqFrom));
    if (opts.seqTo !== undefined) qs.set('seq_to', String(opts.seqTo));
    if (opts.limit !== undefined) qs.set('limit', String(opts.limit));
    const url = `${guildEndpoint}/api/channels/${channelId}/messages` + (qs.toString() ? `?${qs}` : '');
    return this.req('GET', url, guildToken);
  }
}

export type CanvasFormat = 'md' | 'html' | 'text';
export type CanvasInput = { title: string; format: CanvasFormat; source: string };
export type FabricCanvas = {
  channelId: string;
  sharerUserId: string;
  title: string;
  format: CanvasFormat;
  source: string;
  version: number;
  createdAt: string;
  updatedAt: string;
};
