import type { FabricClient } from './fabric-client.js';
import type { IdentityRegistry } from './identity.js';
import type { SubDiscussionStore } from './sub-discussion-store.js';
import { resolveCommandsSyncKey } from './accounts.js';

// OpenClaw tool registration api (loose typing — concrete shape from
// openclaw/plugin-sdk/core at host SDK version).
type ToolApi = {
  registerTool: (def: unknown) => void;
  logger: { info: (m: string) => void; warn: (m: string) => void };
};

type Ctx = { agentId?: string };

// Loose config shape — just the fields we read here.
type ToolsCfg = { channels?: { fabric?: { commandsSyncKey?: string } }; [k: string]: unknown };

const X_BY_KIND: Record<string, string> = {
  chat: 'general',
  work: 'work',
  report: 'report',
  discussion: 'discuss',
};

// Delay between create-sub-discussion's channel create and greeting post.
// Backend pushes channel.joined to invitee sockets on create; that push
// has to traverse socket.io rooms before the guest plugin can sub the
// channel:<id> room. If the greeting is posted before that, the guest's
// turn-activation wakeup misses (the socket isn't in the room yet).
// 500ms is empirically slack enough on local sim + production t3, and
// short enough not to feel laggy from the host's tool-result POV. Bump
// via FABRIC_SUB_DISCUSSION_GREETING_DELAY_MS env if needed.
const GREETING_DELAY_MS = (() => {
  const v = Number.parseInt(process.env.FABRIC_SUB_DISCUSSION_GREETING_DELAY_MS ?? '', 10);
  return Number.isFinite(v) && v >= 0 ? v : 500;
})();

// PaddedCell tools-cache integration (cross-runtime alignment with
// Plexum decision #37; openclaw lacks a before_outgoing_tools hook so we
// opt in per-plugin). All Fabric tools are gate-able by default — agents
// must `dynamic-cache-tools` them before the model sees them.
// `gatedRegister` wraps openclaw's api.registerTool: each factory invocation
//   (1) registers the tool's name+description into PaddedCell's catalog
//       so dynamic-list-tools / dynamic-search-tools surface it
//   (2) returns null if the per-session cache doesn't include the name
// Buffer-drain pattern handles plugin load order — if PaddedCell hasn't
// loaded yet, the stub default-allows + queues catalog entries; PaddedCell
// drains the queue when it installs the real API.
function ensurePaddedStub(): void {
  const g = globalThis as unknown as { __padded?: Record<string, unknown> & { _pendingCatalog?: Array<{ name: string; description: string }>; allowTool?: unknown; registerCatalogEntry?: unknown } };
  if (g.__padded) return;
  const buf: Array<{ name: string; description: string }> = [];
  g.__padded = {
    _pendingCatalog: buf,
    registerCatalogEntry(name: string, description: string): void {
      buf.push({ name, description });
    },
    allowTool: () => true, // fail-open until PaddedCell installs the real API
  };
}

export function registerFabricTools(
  api: ToolApi,
  client: FabricClient,
  identity: IdentityRegistry,
  store: SubDiscussionStore,
  cfg: ToolsCfg,
): void {
  ensurePaddedStub();
  const seenForCatalog = new Set<string>();
  const gatedRegister = (factory: (ctx: Ctx) => unknown | null): void => {
    api.registerTool((ctx: Ctx) => {
      const tool = factory(ctx) as { name?: string; description?: string } | null;
      if (!tool || !tool.name) return tool;
      const padded = (globalThis as unknown as { __padded?: { allowTool?: (n: string, c: Ctx) => boolean; registerCatalogEntry?: (n: string, d: string) => void } }).__padded;
      if (padded?.registerCatalogEntry && !seenForCatalog.has(tool.name)) {
        padded.registerCatalogEntry(tool.name, tool.description ?? '');
        seenForCatalog.add(tool.name);
      }
      if (padded?.allowTool && !padded.allowTool(tool.name, ctx)) return null;
      return tool;
    });
  };
  // Resolve the calling agent's Fabric session + a guild's token/endpoint.
  const ctxGuild = async (agentId: string, guildNodeId: string) => {
    const entry = identity.findByAgentId(agentId);
    if (!entry)
      throw new Error(
        `agent ${agentId} not registered — call the openclaw \`fabric-register\` ` +
          `tool (apiKey: <fak_…>, agentId: ${agentId}); the dynamic-subscription ` +
          `path brings the socket up immediately, no gateway restart needed`,
      );
    const session = await client.agentLogin(entry.fabricApiKey);
    const guild = session.guilds.find((g) => g.nodeId === guildNodeId);
    const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
    if (!guild || !token) throw new Error(`agent not a member of guild ${guildNodeId}`);
    return { session, guild, token };
  };

  // Bind an agent's Fabric API key — validates the key against Center,
  // upserts ~/.openclaw/fabric-identity.json, AND brings up the inbound
  // socket immediately via the live FabricInbound instance (no gateway
  // restart). The standalone binary `~/.openclaw/bin/fabric-register`
  // still exists for one-time bootstrap before the gateway runs, but
  // recruitment's `register-agent` script should prefer this tool path
  // so the new agent's socket is live before `interviewer` fires.
  gatedRegister((ctx: Ctx) => ({
    name: 'fabric-register',
    description:
      'Bind an agent to a Fabric Center API key. Validates the key, writes ' +
      'the entry to ~/.openclaw/fabric-identity.json, and starts a live ' +
      'inbound socket immediately so the agent receives Fabric pushes ' +
      'without a gateway restart. Caller defaults to the current agent; ' +
      'pass `agentId` to bind on behalf of another agent (recruitment use).',
    parameters: {
      type: 'object',
      additionalProperties: false,
      required: ['apiKey'],
      properties: {
        apiKey: { type: 'string', description: 'Fabric Center API key (`fak_…`)' },
        agentId: {
          type: 'string',
          description:
            'Agent to register. Defaults to the calling agent (ctx.agentId). ' +
            'Recruitment onboarding may override this when wiring a freshly ' +
            'created agent before that agent has a session of its own.',
        },
      },
    },
    execute: async (_id: string, p: { apiKey: string; agentId?: string }) => {
      const agentId = p.agentId ?? ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context (pass agentId)' };
      if (!p.apiKey || typeof p.apiKey !== 'string') {
        return { ok: false, error: 'apiKey required' };
      }
      // Delegate to FabricInbound.addAccount via the cross-plugin bridge.
      // The bridge is installed in index.ts when inbound spins up; if it's
      // not present yet, the gateway is still starting and the caller should
      // retry (rare path — only hit during the gateway_start window).
      const fabricApi = (globalThis as Record<string, unknown>)['__fabric'] as
        | { addAccount?: (entry: { agentId: string; fabricApiKey: string }) => Promise<void> }
        | undefined;
      if (!fabricApi?.addAccount) {
        return {
          ok: false,
          error:
            'fabric inbound not ready (gateway still starting?). Fall back to ' +
            '~/.openclaw/bin/fabric-register or retry after a few seconds.',
        };
      }
      try {
        await fabricApi.addAccount({ agentId, fabricApiKey: p.apiKey });
      } catch (err) {
        return {
          ok: false,
          error: `fabric-register failed: ${String(err)}`,
        };
      }
      const entry = identity.findByAgentId(agentId);
      return {
        ok: true,
        agentId,
        fabricUserId: entry?.fabricUserId,
        displayName: entry?.displayName,
      };
    },
  }));

  const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') =>
    gatedRegister((ctx: Ctx) => ({
      name: `create-${kind}-channel`,
      description:
        `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}). ` +
        'Optionally pass `purpose` to describe what this channel is for — ' +
        'agents browse channels by purpose via fabric-channel-list.',
      parameters: {
        type: 'object',
        additionalProperties: false,
        required: ['guildNodeId', 'name'],
        properties: {
          guildNodeId: { type: 'string' },
          name: { type: 'string' },
          isPublic: { type: 'boolean' },
          memberUserIds: { type: 'array', items: { type: 'string' } },
          onDuty: { type: 'string', description: 'required for triage-like flows (unused for these kinds)' },
          listeners: { type: 'array', items: { type: 'string' } },
          purpose: {
            type: 'string',
            description:
              "Free-form description of what this channel is for. Optional but " +
              'strongly recommended so other agents can find this channel by ' +
              'intent (via fabric-channel-list). Can be edited later with ' +
              'fabric-channel-set-purpose.',
          },
        },
      },
      execute: async (_id: string, p: {
        guildNodeId: string;
        name: string;
        isPublic?: boolean;
        memberUserIds?: string[];
        purpose?: string;
      }) => {
        const agentId = ctx.agentId;
        if (!agentId) return { ok: false, error: 'no agent context' };
        const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
        const ch = await client.createChannel(guild.endpoint, token, {
          guildId: p.guildNodeId,
          name: p.name,
          xType: X_BY_KIND[kind],
          isPublic: p.isPublic ?? false,
          memberUserIds: p.memberUserIds ?? [],
          ...(p.purpose !== undefined ? { purpose: p.purpose } : {}),
        });
        return { ok: true, channelId: ch.id };
      },
    }));

  makeCreate('chat');
  makeCreate('work');
  makeCreate('report');
  makeCreate('discussion');

  // discussion-complete: post a summary then close the channel (Guild
  // /channels/:id/close — history stays readable, new posts -> 409).
  gatedRegister((ctx: Ctx) => ({
    name: 'discussion-complete',
    description: 'Conclude a discussion: post a summary then close the channel.',
    parameters: {
      type: 'object',
      additionalProperties: false,
      required: ['guildNodeId', 'channelId', 'summary'],
      properties: {
        guildNodeId: { type: 'string' },
        channelId: { type: 'string' },
        summary: { type: 'string' },
        callbackChannelId: { type: 'string', description: 'optional channel to also post the summary to' },
      },
    },
    execute: async (_id: string, p: {
      guildNodeId: string;
      channelId: string;
      summary: string;
      callbackChannelId?: string;
    }) => {
      const agentId = ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context' };
      const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId);
      await client.postMessage(guild.endpoint, token, p.channelId, p.summary, session.user.id);
      if (p.callbackChannelId) {
        await client
          .postMessage(guild.endpoint, token, p.callbackChannelId, p.summary, session.user.id)
          .catch(() => undefined);
      }
      await client.closeChannel(guild.endpoint, token, p.channelId);
      return { ok: true, closed: true };
    },
  }));

  // ───────────────────────────────────────────────────────────────────
  // create-sub-discussion: open a discuss-type sub-channel hanging off
  // the caller's current channel. Designed for host-driven multi-turn
  // exchanges (interview, brainstorm, narrow Q&A) where the guests are
  // either fresh agents without workflow capability (recruitment
  // interviewee) or peers that just need a short scoped chat without
  // entering their own subflow.
  //
  // What it does on top of plain create-discussion-channel:
  //   1. Persists a store entry indexed by the new sub channelId, carrying:
  //        host agent + userId, guest userIds, host/guest guide texts,
  //        callback (parent) channel info.
  //   2. Auto-posts `greetingMsg` using the host's own Fabric account so
  //      turn rotation's activation rule (first author → newOrder[0],
  //      currentSpeaker → newOrder[1], wakeup → newOrder[1]) puts the
  //      first guest on the spot immediately — no race where host posts
  //      before guest's socket subs the channel room (we wait
  //      GREETING_DELAY_MS for backend's channel.joined push to land).
  //   3. The accompanying before_prompt_build hook (sub-discussion-hook
  //      registered from index.ts) then injects `hostGuideMsg` into the
  //      host's session prompt and `guestGuideMsg` into each guest's
  //      session prompt whenever a turn in this channel fires — so the
  //      two roles see different instructions, no shared guide file.
  // ───────────────────────────────────────────────────────────────────
  gatedRegister((ctx: Ctx) => ({
    name: 'create-sub-discussion',
    description:
      'Open a host-driven sub-discussion channel (x_type=discuss) hanging off your current channel, ' +
      'with role-specific system-prompt guides for host and guests. Use this for interviews / scoped ' +
      'Q&A where you stay in control of when the conversation ends. Returns the sub channelId; ' +
      'reach it via fabric-send-message in the rotating turn order. Close with close-sub-discussion ' +
      'to write a callback back into the parent channel.',
    parameters: {
      type: 'object',
      additionalProperties: false,
      required: [
        'guildNodeId',
        'currentChannelId',
        'channelName',
        'greetingMsg',
        'hostGuideMsg',
        'guestGuideMsg',
        'guests',
      ],
      properties: {
        guildNodeId: { type: 'string', description: 'Fabric guild node id (same guild for parent + sub).' },
        currentChannelId: {
          type: 'string',
          description: 'Channel id you are currently in (parent). Used as the callback target on close.',
        },
        channelName: { type: 'string', description: 'Display name for the new sub-discussion channel.' },
        greetingMsg: {
          type: 'string',
          description:
            'First message posted by YOU (the host) in the sub channel. Triggers turn rotation so ' +
            "the first guest's session wakes immediately with both your greeting in history and the " +
            'guest guide injected as system prompt.',
        },
        hostGuideMsg: {
          type: 'string',
          description:
            "Appended to YOUR session's system prompt whenever a turn fires in this sub channel. " +
            'Use it to remind yourself of the procedure (what to ask, when to call close-sub-discussion).',
        },
        guestGuideMsg: {
          type: 'string',
          description:
            "Appended to EACH GUEST's session system prompt for turns in this sub channel. Use it to " +
            'orient guests with no prior workflow context (e.g. a fresh interviewee). Keep it short; ' +
            'long guides bloat every turn.',
        },
        guests: {
          type: 'array',
          items: { type: 'string' },
          minItems: 1,
          description:
            'Fabric Center userIds invited as guests. Resolve via fabric-channel-list members or the ' +
            '<name>@<role>.hangman-lab.top email convention.',
        },
        purpose: { type: 'string', description: 'Optional channel.purpose for discoverability.' },
      },
    },
    execute: async (
      _id: string,
      p: {
        guildNodeId: string;
        currentChannelId: string;
        channelName: string;
        greetingMsg: string;
        hostGuideMsg: string;
        guestGuideMsg: string;
        guests: string[];
        purpose?: string;
      },
    ) => {
      const agentId = ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context' };
      if (!Array.isArray(p.guests) || p.guests.length === 0) {
        return { ok: false, error: 'guests must be a non-empty array of Fabric userIds' };
      }
      const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId);
      const ch = await client.createChannel(guild.endpoint, token, {
        guildId: p.guildNodeId,
        name: p.channelName,
        xType: 'discuss',
        isPublic: false,
        memberUserIds: p.guests,
        ...(p.purpose !== undefined ? { purpose: p.purpose } : {}),
      });
      store.add({
        subChannelId: ch.id,
        hostAgentId: agentId,
        hostUserId: session.user.id,
        guestUserIds: [...p.guests],
        hostGuide: p.hostGuideMsg,
        guestGuide: p.guestGuideMsg,
        callbackGuildNodeId: p.guildNodeId,
        callbackChannelId: p.currentChannelId,
        createdAt: new Date().toISOString(),
      });
      // Let backend's channel.joined push reach guest sockets before our
      // greeting fires — otherwise the wakeup emitted by turn-activation
      // races a not-yet-subscribed socket.io room.
      if (GREETING_DELAY_MS > 0) {
        await new Promise((r) => setTimeout(r, GREETING_DELAY_MS));
      }
      try {
        await client.postMessage(guild.endpoint, token, ch.id, p.greetingMsg, session.user.id);
      } catch (err) {
        api.logger.warn(
          `fabric: create-sub-discussion greeting post failed channel=${ch.id} err=${String(err)}`,
        );
      }
      return { ok: true, subChannelId: ch.id };
    },
  }));

  // ───────────────────────────────────────────────────────────────────
  // close-sub-discussion: post a system-authored callback into the
  // parent channel + close the sub-discussion channel. Only the original
  // host can call this. Uses the Guild's x-fabric-system-key path (shared
  // secret = commandsSyncKey) so the callback lands as a guild/system
  // author, not the host's personal account — and can wake the host on
  // the parent channel to continue whatever workflow opened the sub.
  // ───────────────────────────────────────────────────────────────────
  gatedRegister((ctx: Ctx) => ({
    name: 'close-sub-discussion',
    description:
      'Close a sub-discussion channel you opened (host-only) and write a callback to the parent ' +
      'channel as a system message. Pass `wakeupHost: false` to land the callback silently in ' +
      'history without waking yourself.',
    parameters: {
      type: 'object',
      additionalProperties: false,
      required: ['subChannelId', 'callbackMsg'],
      properties: {
        subChannelId: { type: 'string', description: 'The sub-discussion channelId returned by create-sub-discussion.' },
        callbackMsg: {
          type: 'string',
          description:
            'Content to post into the parent channel as a system-authored message. Typical content: ' +
            'the conclusion / extracted data from the sub-discussion, so the next turn on the parent ' +
            'channel can act on it.',
        },
        wakeupHost: {
          type: 'boolean',
          description:
            'Whether to wake YOU (the host) on the parent channel. Default true — for recruitment ' +
            'interview flow where the next workflow step needs to run immediately. Pass false for ' +
            'fire-and-forget logging.',
        },
      },
    },
    execute: async (
      _id: string,
      p: { subChannelId: string; callbackMsg: string; wakeupHost?: boolean },
    ) => {
      const agentId = ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context' };
      const entry = store.find(p.subChannelId);
      if (!entry) {
        return { ok: false, error: `sub-discussion not found: ${p.subChannelId}` };
      }
      if (entry.hostAgentId !== agentId) {
        return {
          ok: false,
          error: `only the host (${entry.hostAgentId}) may close this sub-discussion`,
        };
      }
      const systemKey = resolveCommandsSyncKey(cfg);
      if (!systemKey) {
        return {
          ok: false,
          error:
            'channels.fabric.commandsSyncKey is not configured — close-sub-discussion needs it for ' +
            'the x-fabric-system-key callback. Configure via openclaw config.',
        };
      }
      const { guild, token } = await ctxGuild(agentId, entry.callbackGuildNodeId);
      const wakeup = p.wakeupHost !== false;
      // 1) Post callback into parent channel via the system-key path.
      const url = `${guild.endpoint}/api/channels/${encodeURIComponent(entry.callbackChannelId)}/messages`;
      const res = await fetch(url, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'x-fabric-system-key': systemKey,
        },
        body: JSON.stringify({
          content: p.callbackMsg,
          wakeupUserId: wakeup ? entry.hostUserId : null,
        }),
      }).catch((err) => {
        throw new Error(`callback POST failed: ${String(err)}`);
      });
      if (!res.ok) {
        const text = await res.text().catch(() => '');
        return {
          ok: false,
          error: `callback POST ${url} -> ${res.status} ${text}`,
        };
      }
      // 2) Close the sub channel using the host's own bearer (the host is
      // a member of the channel — channel.close auth is per-member).
      try {
        await client.closeChannel(guild.endpoint, token, entry.subChannelId);
      } catch (err) {
        api.logger.warn(
          `fabric: close-sub-discussion: sub channel close failed channel=${entry.subChannelId} err=${String(err)}`,
        );
      }
      // 3) Drop the store entry so the prompt-injection hook stops firing
      // for this channel (the sub is closed; any straggler turns would
      // just hit the closed-channel write reject downstream).
      store.remove(entry.subChannelId);
      return { ok: true, closed: true };
    },
  }));

  // fabric-canvas: share / update / read / close the channel's single
  // pinned canvas document (one tool, four actions). update/close are
  // sharer-only server-side (the guild returns 403 otherwise).
  gatedRegister((ctx: Ctx) => ({
    name: 'fabric-canvas',
    description:
      "Manage a channel's pinned canvas document. action: " +
      "read (current canvas or null) | share (create/replace; you become " +
      'the sharer) | update (edit in place; sharer only) | close (remove; ' +
      'sharer only).',
    parameters: {
      type: 'object',
      additionalProperties: false,
      required: ['action', 'guildNodeId', 'channelId'],
      properties: {
        action: { type: 'string', enum: ['read', 'share', 'update', 'close'] },
        guildNodeId: { type: 'string' },
        channelId: { type: 'string' },
        title: { type: 'string', description: 'share: required; update: optional' },
        format: {
          type: 'string',
          enum: ['md', 'html', 'text'],
          description: 'share: required; update: optional',
        },
        source: {
          type: 'string',
          description: 'document body. share: required; update: optional',
        },
      },
    },
    execute: async (_id: string, p: {
      action: 'read' | 'share' | 'update' | 'close';
      guildNodeId: string;
      channelId: string;
      title?: string;
      format?: 'md' | 'html' | 'text';
      source?: string;
    }) => {
      const agentId = ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context' };
      const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
      const ep = guild.endpoint;
      switch (p.action) {
        case 'read': {
          const canvas = await client.getCanvas(ep, token, p.channelId);
          return { ok: true, canvas };
        }
        case 'share': {
          if (!p.title || !p.format || p.source === undefined) {
            return { ok: false, error: 'share requires title, format, and source' };
          }
          const canvas = await client.shareCanvas(ep, token, p.channelId, {
            title: p.title,
            format: p.format,
            source: p.source,
          });
          return { ok: true, canvas };
        }
        case 'update': {
          const body: Partial<{ title: string; format: 'md' | 'html' | 'text'; source: string }> = {};
          if (p.title !== undefined) body.title = p.title;
          if (p.format !== undefined) body.format = p.format;
          if (p.source !== undefined) body.source = p.source;
          if (Object.keys(body).length === 0) {
            return { ok: false, error: 'update needs at least one of title/format/source' };
          }
          const canvas = await client.updateCanvas(ep, token, p.channelId, body);
          return { ok: true, canvas };
        }
        case 'close': {
          await client.removeCanvas(ep, token, p.channelId);
          return { ok: true, removed: true };
        }
        default:
          return { ok: false, error: `unknown action ${String(p.action)}` };
      }
    },
  }));

  // fabric-channel: channel membership (one tool, three actions).
  gatedRegister((ctx: Ctx) => ({
    name: 'fabric-channel',
    description:
      'Channel membership. action: members (list channel member userIds) | ' +
      'join (this agent joins the channel) | leave (this agent leaves).',
    parameters: {
      type: 'object',
      additionalProperties: false,
      required: ['action', 'guildNodeId', 'channelId'],
      properties: {
        action: { type: 'string', enum: ['members', 'join', 'leave'] },
        guildNodeId: { type: 'string' },
        channelId: { type: 'string' },
      },
    },
    execute: async (_id: string, p: {
      action: 'members' | 'join' | 'leave';
      guildNodeId: string;
      channelId: string;
    }) => {
      const agentId = ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context' };
      const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
      const ep = guild.endpoint;
      switch (p.action) {
        case 'members': {
          const members = await client.channelMembers(ep, token, p.channelId);
          return { ok: true, members };
        }
        case 'join': {
          await client.joinChannel(ep, token, p.channelId);
          return { ok: true, joined: true };
        }
        case 'leave': {
          await client.leaveChannel(ep, token, p.channelId);
          return { ok: true, left: true };
        }
        default:
          return { ok: false, error: `unknown action ${String(p.action)}` };
      }
    },
  }));

  // -----------------------------------------------------------------
  // fabric-send-message: post a message into a specific channel.
  //
  // Unlike a normal channel reply (which goes back to whatever channel
  // woke the agent), this lets the agent proactively initiate text into
  // any channel they are a member of — e.g. ARD broadcasting daily
  // workload to #agents-room, or triage agent following up on an
  // already-routed task by commenting in #updates.
  // -----------------------------------------------------------------
  gatedRegister((ctx: Ctx) => ({
    name: 'fabric-send-message',
    description:
      'Send a text message into a specific Fabric channel. Author is the calling agent. ' +
      'Requires guildNodeId + channelId + content. Returns {ok, messageId, seq}.',
    parameters: {
      type: 'object',
      additionalProperties: false,
      required: ['guildNodeId', 'channelId', 'content'],
      properties: {
        guildNodeId: { type: 'string' },
        channelId: { type: 'string' },
        content: { type: 'string', description: 'Message body (markdown supported by the renderer).' },
      },
    },
    execute: async (_id: string, p: { guildNodeId: string; channelId: string; content: string }) => {
      const agentId = ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context' };
      const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId);
      const res = (await client.postMessage(
        guild.endpoint,
        token,
        p.channelId,
        p.content,
        session.user.id,
      )) as { messageId?: string; seq?: number };
      return { ok: true, messageId: res.messageId, seq: res.seq };
    },
  }));

  // ───────────────────────────────────────────────────────────────────
  // fabric-send-sys-msg: post a system-authored message (author =
  // sentinel UUID 0000…, not the calling agent) using the Guild's
  // x-fabric-system-key path. Use for cross-agent broadcasts where you
  // don't want the message tied to one agent's identity — Dialectic
  // topic announcements / lifecycle events, host-system advisories,
  // etc. Caller doesn't need to be a member of the channel (the
  // backend isSystem branch skips assertParticipant), but must be a
  // member of the guild (their session resolves the guild endpoint).
  //
  // Shared secret: reads channels.fabric.commandsSyncKey (same value
  // as the guild's FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY env). Empty
  // config → tool returns ok:false with a clear error, no fall-through
  // to regular agent posting.
  // ───────────────────────────────────────────────────────────────────
  gatedRegister((ctx: Ctx) => ({
    name: 'fabric-send-sys-msg',
    description:
      'Send a SYSTEM-AUTHORED message into a Fabric channel (author = guild sentinel, not you). ' +
      'Use for cross-agent broadcasts that should not be attributed to a single agent — ' +
      'Dialectic announce-channel topic broadcasts, lifecycle events, system advisories. ' +
      'Optionally precise-wake one recipient via wakeupUserId; otherwise the message lands ' +
      'silently in history (no wake).',
    parameters: {
      type: 'object',
      additionalProperties: false,
      required: ['guildNodeId', 'channelId', 'content'],
      properties: {
        guildNodeId: { type: 'string' },
        channelId: { type: 'string' },
        content: { type: 'string', description: 'Message body (markdown supported by the renderer).' },
        wakeupUserId: {
          type: 'string',
          description:
            "Optional: a single Fabric userId to wake with this message (everyone else in the " +
            'channel sees it but with wakeup=false). Omit for fully silent broadcast.',
        },
      },
    },
    execute: async (
      _id: string,
      p: { guildNodeId: string; channelId: string; content: string; wakeupUserId?: string },
    ) => {
      const agentId = ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context' };
      const systemKey = resolveCommandsSyncKey(cfg);
      if (!systemKey) {
        return {
          ok: false,
          error:
            'channels.fabric.commandsSyncKey is not configured — fabric-send-sys-msg needs it for ' +
            'the x-fabric-system-key header. Configure via openclaw config.',
        };
      }
      const { guild } = await ctxGuild(agentId, p.guildNodeId);
      const url = `${guild.endpoint}/api/channels/${encodeURIComponent(p.channelId)}/messages`;
      const wakeup = typeof p.wakeupUserId === 'string' && p.wakeupUserId.trim()
        ? p.wakeupUserId.trim()
        : null;
      const res = await fetch(url, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'x-fabric-system-key': systemKey,
        },
        body: JSON.stringify({ content: p.content, wakeupUserId: wakeup }),
      });
      if (!res.ok) {
        const text = await res.text().catch(() => '');
        return { ok: false, error: `POST ${url} -> ${res.status} ${text}` };
      }
      const json = (await res.json().catch(() => null)) as
        | { messageId?: string; seq?: number; authorUserId?: string }
        | null;
      return {
        ok: true,
        messageId: json?.messageId,
        seq: json?.seq,
        authorUserId: json?.authorUserId,
      };
    },
  }));

  // -----------------------------------------------------------------
  // fabric-channel-list: enumerate channels the calling agent can see
  // in a given guild. Backend filters to public channels + channels the
  // agent is a member of. Returns id / name / xType per channel so the
  // agent can pick a channelId for fabric-send-message etc.
  // -----------------------------------------------------------------
  gatedRegister((ctx: Ctx) => ({
    name: 'fabric-channel-list',
    description:
      'List channels visible to the calling agent in a guild. Optional ' +
      'nameFilter does a case-insensitive substring match client-side. ' +
      'Use this to find a channelId before fabric-send-message / fabric-message-history.',
    parameters: {
      type: 'object',
      additionalProperties: false,
      required: ['guildNodeId'],
      properties: {
        guildNodeId: { type: 'string' },
        nameFilter: { type: 'string', description: 'optional substring match on channel name (case-insensitive)' },
        xType: {
          type: 'string',
          enum: ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'],
          description: 'optional filter by x_type',
        },
        includeClosed: { type: 'boolean', description: 'default false — closed channels filtered out' },
      },
    },
    execute: async (_id: string, p: {
      guildNodeId: string;
      nameFilter?: string;
      xType?: string;
      includeClosed?: boolean;
    }) => {
      const agentId = ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context' };
      const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
      const all = await client.listChannels(guild.endpoint, token, p.guildNodeId);
      const needle = (p.nameFilter ?? '').toLowerCase();
      const filtered = all.filter((c) => {
        if (!p.includeClosed && c.closed) return false;
        if (p.xType && c.xType !== p.xType) return false;
        if (needle && !c.name.toLowerCase().includes(needle)) return false;
        return true;
      });
      return {
        ok: true,
        count: filtered.length,
        channels: filtered.map((c) => ({
          id: c.id,
          name: c.name,
          xType: c.xType,
          isPublic: c.isPublic,
          closed: c.closed,
          lastSeq: c.lastSeq,
          purpose: c.purpose ?? null,
        })),
      };
    },
  }));

  // -----------------------------------------------------------------
  // fabric-guild-list: enumerate guilds the calling agent belongs to.
  // Each row carries `purpose` — free-form description of what the
  // guild is for (admin-set). Use this as the first step when a
  // workflow says "find the right guild for X" — pick by purpose,
  // then fabric-channel-list to find the right channel inside it.
  // -----------------------------------------------------------------
  gatedRegister((ctx: Ctx) => ({
    name: 'fabric-guild-list',
    description:
      'List guilds the calling agent is a member of. Returns ' +
      '{nodeId, name, purpose, status} per row. ' +
      "`purpose` is a free-form description of what each guild is for — " +
      'pick the guild whose purpose matches your intent. Use this tool ' +
      'BEFORE fabric-channel-list when a workflow asks you to pick the ' +
      'right guild by intent (e.g. "find a guild whose purpose mentions ' +
      'debate broadcasts" → then list its announce-type channels).',
    parameters: {
      type: 'object',
      additionalProperties: false,
      properties: {
        nameFilter: {
          type: 'string',
          description: 'optional case-insensitive substring match on guild name',
        },
        purposeFilter: {
          type: 'string',
          description:
            'optional case-insensitive substring match on guild purpose ' +
            '(e.g. "debate", "announcements")',
        },
      },
    },
    execute: async (_id: string, p: { nameFilter?: string; purposeFilter?: string }) => {
      const agentId = ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context' };
      const entry = identity.findByAgentId(agentId);
      if (!entry) return { ok: false, error: `agent ${agentId} not registered` };
      const session = await client.agentLogin(entry.fabricApiKey);
      const nameNeedle = (p.nameFilter ?? '').toLowerCase();
      const purposeNeedle = (p.purposeFilter ?? '').toLowerCase();
      const guilds = session.guilds.filter((g) => {
        if (nameNeedle && !g.name.toLowerCase().includes(nameNeedle)) return false;
        if (purposeNeedle) {
          const purp = (g.purpose ?? '').toLowerCase();
          if (!purp.includes(purposeNeedle)) return false;
        }
        return true;
      });
      return {
        ok: true,
        count: guilds.length,
        guilds: guilds.map((g) => ({
          nodeId: g.nodeId,
          name: g.name,
          status: g.status,
          purpose: g.purpose ?? null,
        })),
      };
    },
  }));

  // -----------------------------------------------------------------
  // fabric-channel-set-purpose: set/update a channel's free-form
  // purpose description. Caller must be a channel member (or the
  // channel must be public). Use this to backfill purpose on existing
  // channels, or to refine it after a channel's role evolves.
  // -----------------------------------------------------------------
  gatedRegister((ctx: Ctx) => ({
    name: 'fabric-channel-set-purpose',
    description:
      "Set or update a channel's free-form purpose description. " +
      'Channel membership required (or the channel must be public). ' +
      'Pass empty string to clear. Use this to make a channel ' +
      'discoverable to other agents via fabric-channel-list.',
    parameters: {
      type: 'object',
      additionalProperties: false,
      required: ['guildNodeId', 'channelId', 'purpose'],
      properties: {
        guildNodeId: { type: 'string' },
        channelId: { type: 'string' },
        purpose: {
          type: 'string',
          description: "What this channel is for. Pass '' (empty string) to clear.",
        },
      },
    },
    execute: async (_id: string, p: { guildNodeId: string; channelId: string; purpose: string }) => {
      const agentId = ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context' };
      const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
      const res = await client.setChannelPurpose(
        guild.endpoint,
        token,
        p.channelId,
        p.purpose,
      );
      return { ok: true, channel: res };
    },
  }));

  // -----------------------------------------------------------------
  // fabric-message-history: read a channel's recent message history by
  // `seq`. Tail-by-default: when `seqFrom`/`seqTo` are omitted, returns
  // the last `limit` messages (limit defaults to 20, max 200).
  //
  // Use cases: catch-up on a channel that was muted while the agent was
  // gated; verify a previous message went through; lookup recent
  // duplicates before opening a new task in triage.
  // -----------------------------------------------------------------
  gatedRegister((ctx: Ctx) => ({
    name: 'fabric-message-history',
    description:
      "Read a channel's recent message history. Omit seqFrom/seqTo to " +
      'tail (last `limit` messages, default 20, max 200). Backend ' +
      'requires the calling agent to be a channel participant.',
    parameters: {
      type: 'object',
      additionalProperties: false,
      required: ['guildNodeId', 'channelId'],
      properties: {
        guildNodeId: { type: 'string' },
        channelId: { type: 'string' },
        seqFrom: { type: 'integer', minimum: 1, description: 'inclusive lower bound; default = tail' },
        seqTo: { type: 'integer', minimum: 1, description: 'inclusive upper bound; default = channel head' },
        limit: { type: 'integer', minimum: 1, maximum: 200, description: 'default 20' },
      },
    },
    execute: async (_id: string, p: {
      guildNodeId: string;
      channelId: string;
      seqFrom?: number;
      seqTo?: number;
      limit?: number;
    }) => {
      const agentId = ctx.agentId;
      if (!agentId) return { ok: false, error: 'no agent context' };
      const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
      const limit = p.limit ?? 20;

      // Tail mode: discover channel head via channel listing, then ask
      // for [head-limit+1, head]. Avoids needing the agent to know seq.
      let seqFrom = p.seqFrom;
      let seqTo = p.seqTo;
      if (seqFrom === undefined && seqTo === undefined) {
        const channels = await client.listChannels(guild.endpoint, token, p.guildNodeId);
        const ch = channels.find((c) => c.id === p.channelId);
        const head = ch?.lastSeq ?? 0;
        seqFrom = Math.max(1, head - limit + 1);
        seqTo = head;
      }

      const res = await client.listMessages(guild.endpoint, token, p.channelId, {
        seqFrom,
        seqTo,
        limit,
      });
      return {
        ok: true,
        page: res.page,
        messages: res.items.map((m) => ({
          messageId: m.messageId,
          seq: m.seq,
          authorUserId: m.authorUserId,
          content: m.content,
          createdAt: m.createdAt,
          isDeleted: m.isDeleted,
        })),
      };
    },
  }));
}
