#!/usr/bin/env bun
/// <reference types="bun-types" />
/**
 * RingCentral MCP server for Claude Code.
 *
 * Env vars:
 *   RINGCENTRAL_CLIENT_ID, RINGCENTRAL_CLIENT_SECRET, RINGCENTRAL_JWT (required),
 *   RINGCENTRAL_SERVER (default: https://platform.ringcentral.com)
 *
 * Transport (Phase C, 2026-05-23): Streamable HTTP via mcp-shared on
 * MCP_PORTS.ringcentral (18813). Requires MCP_BEARER_TOKEN env. The Tailscale
 * funnel at /ringcentral terminates TLS and proxies to 127.0.0.1:18813.
 */

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { serveMcpOverHttp, MCP_PORTS } from "../mcp-shared/index.ts";

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

const CLIENT_ID = process.env.RINGCENTRAL_CLIENT_ID ?? "";
const CLIENT_SECRET = process.env.RINGCENTRAL_CLIENT_SECRET ?? "";
const JWT = process.env.RINGCENTRAL_JWT ?? "";
const RC_SERVER = process.env.RINGCENTRAL_SERVER ?? "https://platform.ringcentral.com";

if (!CLIENT_ID || !CLIENT_SECRET || !JWT) {
  process.stderr.write(
    "ringcentral-mcp: RINGCENTRAL_CLIENT_ID, RINGCENTRAL_CLIENT_SECRET, and RINGCENTRAL_JWT are required\n",
  );
  process.exit(1);
}

process.on("unhandledRejection", (e) =>
  process.stderr.write(`ringcentral-mcp: unhandled rejection: ${String(e)}\n`),
);
process.on("uncaughtException", (e) =>
  process.stderr.write(`ringcentral-mcp: uncaught exception: ${String(e)}\n`),
);

// ---------------------------------------------------------------------------
// OAuth token management (JWT grant)
// ---------------------------------------------------------------------------

let accessToken: string | null = null;
let tokenExpiresAt = 0;

async function authenticate(): Promise<string> {
  if (accessToken && Date.now() < tokenExpiresAt - 60_000) {
    return accessToken;
  }
  const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
  const res = await fetch(`${RC_SERVER}/restapi/oauth/token`, {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
      assertion: JWT,
    }).toString(),
  });
  if (!res.ok) {
    throw new Error(`Auth failed (${res.status}): ${await res.text()}`);
  }
  const data = (await res.json()) as { access_token: string; expires_in: number };
  accessToken = data.access_token;
  tokenExpiresAt = Date.now() + data.expires_in * 1000;
  process.stderr.write("ringcentral-mcp: authenticated\n");
  return accessToken;
}

// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------

const SAFE_ID = /^[a-zA-Z0-9~_-]+$/;

function validateId(value: unknown, name: string): string {
  const s = String(value);
  if (!SAFE_ID.test(s)) {
    throw new Error(`Invalid ${name}: must be alphanumeric`);
  }
  return s;
}

const ALLOW_WRITES = process.env.RC_ALLOW_WRITES === "1";

// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------

async function rcGet(path: string, params?: Record<string, string>): Promise<unknown> {
  const token = await authenticate();
  const url = new URL(`${RC_SERVER}${path}`);
  if (params) {
    for (const [k, v] of Object.entries(params)) {
      if (v) {
        url.searchParams.set(k, v);
      }
    }
  }
  const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) {
    throw new Error(`GET ${path} (${res.status}): ${await res.text()}`);
  }
  return res.json();
}

async function rcPost(path: string, body: Record<string, unknown>): Promise<unknown> {
  const token = await authenticate();
  const res = await fetch(`${RC_SERVER}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    throw new Error(`POST ${path} (${res.status}): ${await res.text()}`);
  }
  return res.json();
}

async function rcPut(path: string, body: Record<string, unknown>): Promise<unknown> {
  const token = await authenticate();
  const res = await fetch(`${RC_SERVER}${path}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    throw new Error(`PUT ${path} (${res.status}): ${await res.text()}`);
  }
  return res.json();
}

/** Build a JSON-schema property entry. */
function prop(type: string, description: string) {
  return { type, description };
}

/** Shorthand to build a tool input schema. */
function schema(
  properties: Record<string, { type: string; description: string }>,
  required?: string[],
) {
  const s: Record<string, unknown> = { type: "object", properties };
  if (required?.length) {
    s.required = required;
  }
  return s;
}

/** Wrap API JSON in an MCP text result. */
function ok(data: unknown) {
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

/** Pick non-undefined string params from args into a Record. */
function pickParams(args: Record<string, unknown>, keys: string[]): Record<string, string> {
  const p: Record<string, string> = {};
  for (const k of keys) {
    if (args[k] !== undefined && args[k] !== "") {
      p[k] = String(args[k] as string | number);
    }
  }
  return p;
}

// ---------------------------------------------------------------------------
// MCP Server
// ---------------------------------------------------------------------------

/**
 * Build a fresh MCP Server instance for a new session.
 *
 * mcp-shared invokes this once per inbound `initialize` so concurrent
 * clients (e.g. Claude Desktop + Codex) each get their own Server --
 * the SDK's Protocol.connect() refuses a second transport on the same
 * Server, so per-session instances are mandatory for multi-client use.
 *
 * RingCentral OAuth token cache lives in module scope and is shared
 * across sessions via the closure -- nothing here is per-Server.
 */
function buildServer(): Server {
  const mcp = new Server(
    { name: "ringcentral-mcp", version: "0.1.0" },
    {
      capabilities: { tools: {} },
      instructions:
        "RingCentral phone system management.\nTools for call logs, extensions, SMS, fax, voicemail, and phone numbers.\nFax number: +14694366913.",
    },
  );

// ---------------------------------------------------------------------------
// Tool definitions
// ---------------------------------------------------------------------------

const PAGE_PROPS = {
  page: prop("number", "Page number"),
  perPage: prop("number", "Results per page"),
};
const DATE_PROPS = {
  dateFrom: prop("string", "Start date (ISO 8601)"),
  dateTo: prop("string", "End date (ISO 8601)"),
};
const EXT_PROP = { extensionId: prop("string", "Extension ID") };

mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "get_account_info",
      description: "Get account details (name, status, operator, service plan).",
      inputSchema: schema({}),
    },
    {
      name: "list_extensions",
      description: "List all extensions (ID, name, number, type, status).",
      inputSchema: schema(PAGE_PROPS),
    },
    {
      name: "get_extension",
      description: "Get detailed info for one extension.",
      inputSchema: schema(EXT_PROP, ["extensionId"]),
    },
    {
      name: "list_call_log",
      description: "Account-level call log with date filtering.",
      inputSchema: schema({ ...DATE_PROPS, ...PAGE_PROPS }),
    },
    {
      name: "get_extension_call_log",
      description: "Call log for a specific extension.",
      inputSchema: schema({ ...EXT_PROP, ...DATE_PROPS, perPage: PAGE_PROPS.perPage }, [
        "extensionId",
      ]),
    },
    {
      name: "list_active_calls",
      description: "List all currently active calls.",
      inputSchema: schema({}),
    },
    {
      name: "send_sms",
      description: "Send SMS from a RingCentral number.",
      inputSchema: schema(
        {
          from: prop("string", "Sender phone number (RC account number)"),
          to: prop("string", "Recipient (E.164, e.g. +19725551234)"),
          text: prop("string", "Message text"),
        },
        ["from", "to", "text"],
      ),
    },
    {
      name: "get_voicemail",
      description: "Get voicemail messages for an extension.",
      inputSchema: schema({ ...EXT_PROP, perPage: PAGE_PROPS.perPage }, ["extensionId"]),
    },
    {
      name: "update_extension",
      description: "Update extension settings (name, status, email).",
      inputSchema: schema(
        {
          ...EXT_PROP,
          status: prop("string", "Enabled | Disabled | NotActivated"),
          name: prop("string", "Display name"),
          email: prop("string", "Contact email"),
        },
        ["extensionId"],
      ),
    },
    {
      name: "list_phone_numbers",
      description: "List all phone numbers on the account.",
      inputSchema: schema(PAGE_PROPS),
    },
    {
      name: "send_fax",
      description:
        "Send a fax. Attach a file (PDF, TIFF, DOC, TXT, or image) from a local path or URL.",
      inputSchema: schema(
        {
          to: prop("string", "Recipient fax number (E.164, e.g. +19725551234)"),
          filePath: prop("string", "Local file path to send as fax attachment"),
          fileUrl: prop("string", "URL of file to download and fax (alternative to filePath)"),
          coverPageText: prop("string", "Optional cover page text"),
        },
        ["to"],
      ),
    },
    {
      name: "list_fax_messages",
      description: "List sent and received fax messages.",
      inputSchema: schema({ ...DATE_PROPS, ...PAGE_PROPS, direction: prop("string", "Inbound | Outbound") }),
    },
  ],
}));

// ---------------------------------------------------------------------------
// Tool handler
// ---------------------------------------------------------------------------

mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
  const args = req.params.arguments ?? {};
  try {
    switch (req.params.name) {
      case "get_account_info":
        return ok(await rcGet("/restapi/v1.0/account/~"));

      case "list_extensions":
        return ok(
          await rcGet("/restapi/v1.0/account/~/extension", pickParams(args, ["page", "perPage"])),
        );

      case "get_extension": {
        const id = validateId(args.extensionId, "extensionId");
        return ok(await rcGet(`/restapi/v1.0/account/~/extension/${id}`));
      }

      case "list_call_log":
        return ok(
          await rcGet(
            "/restapi/v1.0/account/~/call-log",
            pickParams(args, ["dateFrom", "dateTo", "perPage", "page"]),
          ),
        );

      case "get_extension_call_log": {
        const id = validateId(args.extensionId, "extensionId");
        return ok(
          await rcGet(
            `/restapi/v1.0/account/~/extension/${id}/call-log`,
            pickParams(args, ["dateFrom", "dateTo", "perPage"]),
          ),
        );
      }

      case "list_active_calls":
        return ok(await rcGet("/restapi/v1.0/account/~/active-calls"));

      case "send_sms": {
        if (!ALLOW_WRITES) {
          throw new Error("send_sms is disabled. Set RC_ALLOW_WRITES=1 to enable.");
        }
        const from = args.from as string,
          to = args.to as string,
          text = args.text as string;
        if (!from || !to || !text) {
          throw new Error("from, to, and text are all required");
        }
        return ok(
          await rcPost("/restapi/v1.0/account/~/extension/~/sms", {
            from: { phoneNumber: from },
            to: [{ phoneNumber: to }],
            text,
          }),
        );
      }

      case "get_voicemail": {
        const id = validateId(args.extensionId, "extensionId");
        return ok(
          await rcGet(`/restapi/v1.0/account/~/extension/${id}/message-store`, {
            messageType: "VoiceMail",
            ...pickParams(args, ["perPage"]),
          }),
        );
      }

      case "update_extension": {
        if (!ALLOW_WRITES) {
          throw new Error("update_extension is disabled. Set RC_ALLOW_WRITES=1 to enable.");
        }
        const id = validateId(args.extensionId, "extensionId");
        const body: Record<string, unknown> = {};
        if (args.status) {
          body.status = args.status;
        }
        if (args.name) {
          body.contact = { firstName: args.name };
        }
        if (args.email) {
          body.contact = { ...(body.contact as Record<string, unknown>), email: args.email };
        }
        if (!Object.keys(body).length) {
          throw new Error("At least one field to update required (status, name, email)");
        }
        return ok(await rcPut(`/restapi/v1.0/account/~/extension/${id}`, body));
      }

      case "list_phone_numbers":
        return ok(
          await rcGet(
            "/restapi/v1.0/account/~/phone-number",
            pickParams(args, ["page", "perPage"]),
          ),
        );

      case "send_fax": {
        if (!ALLOW_WRITES) {
          throw new Error("send_fax is disabled. Set RC_ALLOW_WRITES=1 to enable.");
        }
        const to = args.to as string;
        const filePath = args.filePath as string | undefined;
        const fileUrl = args.fileUrl as string | undefined;
        const coverPageText = args.coverPageText as string | undefined;
        if (!to) throw new Error("to is required");
        if (!filePath && !fileUrl) throw new Error("filePath or fileUrl is required");

        const token = await authenticate();
        const boundary = `----faxboundary${Date.now()}`;

        const faxJson = JSON.stringify({
          to: [{ phoneNumber: to }],
          faxResolution: "High",
          ...(coverPageText ? { coverPageText } : {}),
        });

        let fileData: Buffer;
        let fileName: string;
        let contentType: string;
        if (filePath) {
          const { readFileSync } = await import("fs");
          const { basename, extname } = await import("path");
          fileData = readFileSync(filePath);
          fileName = basename(filePath);
          const ext = extname(filePath).toLowerCase();
          const mimeMap: Record<string, string> = {
            ".pdf": "application/pdf",
            ".tif": "image/tiff",
            ".tiff": "image/tiff",
            ".png": "image/png",
            ".jpg": "image/jpeg",
            ".jpeg": "image/jpeg",
            ".doc": "application/msword",
            ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
            ".txt": "text/plain",
          };
          contentType = mimeMap[ext] ?? "application/octet-stream";
        } else {
          const resp = await fetch(fileUrl!);
          if (!resp.ok) throw new Error(`Failed to download ${fileUrl}: ${resp.status}`);
          fileData = Buffer.from(await resp.arrayBuffer());
          const urlPath = new URL(fileUrl!).pathname;
          fileName = urlPath.split("/").pop() ?? "fax-document";
          contentType = resp.headers.get("content-type") ?? "application/pdf";
        }

        const parts: Buffer[] = [];
        parts.push(Buffer.from(
          `--${boundary}\r\nContent-Type: application/json\r\nContent-Disposition: form-data; name="json"\r\n\r\n${faxJson}\r\n`
        ));
        parts.push(Buffer.from(
          `--${boundary}\r\nContent-Type: ${contentType}\r\nContent-Disposition: form-data; name="attachment"; filename="${fileName}"\r\n\r\n`
        ));
        parts.push(fileData);
        parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
        const body = Buffer.concat(parts);

        const res = await fetch(`${RC_SERVER}/restapi/v1.0/account/~/extension/~/fax`, {
          method: "POST",
          headers: {
            Authorization: `Bearer ${token}`,
            "Content-Type": `multipart/form-data; boundary=${boundary}`,
          },
          body,
        });
        if (!res.ok) {
          throw new Error(`Fax send failed (${res.status}): ${await res.text()}`);
        }
        return ok(await res.json());
      }

      case "list_fax_messages":
        return ok(
          await rcGet("/restapi/v1.0/account/~/extension/~/message-store", {
            messageType: "Fax",
            ...pickParams(args, ["dateFrom", "dateTo", "perPage", "page", "direction"]),
          }),
        );

      default:
        return {
          content: [{ type: "text" as const, text: `Unknown tool: ${req.params.name}` }],
          isError: true,
        };
    }
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    process.stderr.write(`ringcentral-mcp: tool error (${req.params.name}): ${msg}\n`);
    return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
  }
});

  return mcp;
}

// ---------------------------------------------------------------------------
// Connect
// ---------------------------------------------------------------------------

const token = process.env.MCP_BEARER_TOKEN;
if (!token) {
  process.stderr.write("ringcentral-mcp: MCP_BEARER_TOKEN required\n");
  process.exit(1);
}

// serveMcpOverHttp is synchronous: it boots Bun.serve and returns the
// handle. We still wrap in try/catch so a bind failure (e.g. EADDRINUSE)
// produces a clean fatal log instead of an unhandled exception.
try {
  serveMcpOverHttp({
    serverFactory: buildServer,
    port: MCP_PORTS.ringcentral,
    token,
  });
} catch (err) {
  const msg = err instanceof Error ? err.message : String(err);
  process.stderr.write(`ringcentral-mcp: fatal startup error: ${msg}\n`);
  process.exit(1);
}

process.stderr.write(
  `ringcentral-mcp: server started on http :${MCP_PORTS.ringcentral} (writes=${ALLOW_WRITES ? "ENABLED" : "disabled"})\n`,
);
