/**
 * Persistent proxy to Slack's native MCP server (https://mcp.slack.com/mcp).
 *
 * Why a proxy: the Claude Agent SDK cold-connects remote MCP servers per turn
 * and can't wait for them, so mcp.slack.com (a ~2-3s handshake) is frequently
 * still "pending" at turn start and its tools miss the model's tool list. This
 * module keeps ONE warm Client connection to mcp.slack.com (per token) and
 * exposes its tools through an in-process (`sdk`) MCP Server — which the SDK
 * connects instantly and DOES wait for — so the Slack tools are reliably present
 * at turn start. Tool calls forward to the warm client. Validated end-to-end:
 * the proxied server reports `connected` at init and calls return real Slack data.
 */

import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { createLogger } from "../lib/logger.ts";

const log = createLogger("slack-mcp-proxy");

export const SLACK_MCP_URL = "https://mcp.slack.com/mcp";

// The MCP client/server types aren't in our dep graph as first-class types; the
// runtime shapes are validated by the SDK. Keep these loose and localized.
/* eslint-disable @typescript-eslint/no-explicit-any */
type AnyClient = any;

/** A warm client entry plus its idle-close timer. */
type WarmEntry = {
  client: AnyClient;
  tools: unknown[] | null;
  idle: ReturnType<typeof setTimeout> | null;
};

/** Warm, persistent clients keyed by the bearer token (one per connected user). */
const warm = new Map<string, WarmEntry>();
const connecting = new Map<string, Promise<AnyClient>>();
/** Bound the map so rotated/orphaned tokens (hosted multi-tenant) can't leak forever. */
const MAX_WARM = 32;
/**
 * Drop the cached session after this much inactivity (reconnect on next use).
 * The "connection" isn't a held-open socket — mcp.slack.com has no SSE stream
 * (returns 405 on GET), so this is a cached session id plus normal HTTP
 * keep-alive; the timeout just means nothing lingers between idle periods.
 * Override with NOMOS_SLACK_MCP_IDLE_MINUTES.
 */
const IDLE_MS = (() => {
  const m = Number(process.env.NOMOS_SLACK_MCP_IDLE_MINUTES);
  return (Number.isFinite(m) && m > 0 ? m : 10) * 60_000;
})();

/** Close a client, swallowing errors (a dead socket often throws on close). */
async function closeQuietly(client: AnyClient): Promise<void> {
  try {
    await client?.close?.();
  } catch {
    // best-effort
  }
}

/** (Re)arm the idle timer: close + forget this entry after IDLE_MS unused. */
function armIdle(token: string, entry: WarmEntry): void {
  if (entry.idle) clearTimeout(entry.idle);
  entry.idle = setTimeout(() => {
    // Only close if this exact entry is still the current one (not replaced).
    if (warm.get(token) === entry) {
      warm.delete(token);
      void closeQuietly(entry.client);
      log.info("closed idle Slack MCP connection");
    }
  }, IDLE_MS);
  entry.idle.unref?.();
}

/** Remove an entry, clearing its idle timer; returns it for cleanup. */
function dropEntry(token: string): WarmEntry | undefined {
  const entry = warm.get(token);
  if (entry?.idle) clearTimeout(entry.idle);
  warm.delete(token);
  return entry;
}

async function connect(token: string): Promise<AnyClient> {
  const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
  const { StreamableHTTPClientTransport } =
    await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
  const client = new Client({ name: "nomos-slack-proxy", version: "1.0.0" }, { capabilities: {} });
  const transport = new StreamableHTTPClientTransport(new URL(SLACK_MCP_URL), {
    requestInit: { headers: { Authorization: `Bearer ${token}` } },
  });
  await client.connect(transport);
  const entry: WarmEntry = { client, tools: null, idle: null };
  warm.set(token, entry);
  armIdle(token, entry);
  connecting.delete(token);
  // Evict + close the oldest entry when over cap (bounds orphaned connections).
  if (warm.size > MAX_WARM) {
    const oldest = warm.keys().next().value;
    if (oldest !== undefined && oldest !== token) {
      const evicted = dropEntry(oldest);
      if (evicted) void closeQuietly(evicted.client);
    }
  }
  log.info("connected persistent Slack MCP client");
  return client;
}

/** Get the warm client for `token`, connecting once if needed (dedup concurrent). */
async function getClient(token: string): Promise<AnyClient> {
  const existing = warm.get(token);
  if (existing) {
    armIdle(token, existing); // extend the idle window on every use
    return existing.client;
  }
  let pending = connecting.get(token);
  if (!pending) {
    pending = connect(token).catch((e) => {
      connecting.delete(token);
      throw e;
    });
    connecting.set(token, pending);
  }
  return pending;
}

/**
 * Run `fn` with the warm client, reconnecting once on a transient failure.
 * Critically, on failure we evict + close ONLY the client we actually used (if
 * it's still the current warm one) and never touch `connecting` — reconnection
 * goes through getClient's dedup, so a concurrent reconnect for the same token
 * isn't clobbered (which would open a duplicate socket and leak a client).
 */
async function withClient<T>(token: string, fn: (client: AnyClient) => Promise<T>): Promise<T> {
  const client = await getClient(token);
  try {
    return await fn(client);
  } catch (err) {
    const entry = warm.get(token);
    if (entry && entry.client === client) {
      dropEntry(token);
      void closeQuietly(client);
    }
    log.warn(
      { err: err instanceof Error ? err.message : err },
      "Slack MCP call failed; reconnecting once",
    );
    return await fn(await getClient(token));
  }
}

/** Close + forget a token's warm connection (e.g. on disconnect / re-auth). */
export async function closeSlackMcp(token: string): Promise<void> {
  const entry = dropEntry(token);
  connecting.delete(token);
  if (entry) await closeQuietly(entry.client);
}

/** Cached tools/list from the warm client (tool set rarely changes per token). */
async function listTools(token: string): Promise<unknown[]> {
  const cached = warm.get(token)?.tools;
  if (cached) return cached;
  return withClient(token, async (client) => {
    const tools = (await client.listTools()).tools ?? [];
    const entry = warm.get(token);
    if (entry) entry.tools = tools;
    return tools;
  });
}

/**
 * Pre-open the connection + prime the tools cache. Call at daemon boot (per
 * connected owner) so the very first turn already has the Slack tools warm.
 */
export async function warmSlackMcp(token: string): Promise<void> {
  try {
    await listTools(token);
  } catch (e) {
    log.warn({ err: e instanceof Error ? e.message : e }, "Slack MCP warm-up failed");
  }
}

/**
 * Build the in-process proxy server exposing mcp.slack.com's tools. Keyed
 * `slack`, so tools surface as `mcp__slack__<tool>`. A fresh (cheap) Server is
 * created per call and shares the singleton warm client for its token.
 */
export async function buildSlackMcpProxyServer(
  token: string,
): Promise<Record<string, McpServerConfig>> {
  const { Server } = await import("@modelcontextprotocol/sdk/server/index.js");
  const { ListToolsRequestSchema, CallToolRequestSchema } =
    await import("@modelcontextprotocol/sdk/types.js");
  const server = new Server({ name: "slack", version: "1.0.0" }, { capabilities: { tools: {} } });
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await listTools(token) }));
  server.setRequestHandler(CallToolRequestSchema, async (req: { params: unknown }) =>
    withClient(token, (client) => client.callTool((req as any).params)),
  );
  // The SDK's `sdk`-type config wants `instance: McpServer`; a low-level Server
  // is structurally compatible here (the SDK connects it via an in-memory
  // transport) — validated live. Cast to satisfy the type.
  const cfg = { type: "sdk", name: "slack", instance: server } as unknown as McpServerConfig;
  return { slack: cfg };
}
/* eslint-enable @typescript-eslint/no-explicit-any */
