#!/usr/bin/env bun
/// <reference types="bun-types" />
/**
 * Curogram patient-messaging MCP server (v1 = messaging scope).
 *
 * Curogram is the HIPAA-compliant 2-way SMS platform Exult Healthcare uses
 * for patient outreach. This server calls the same private API the
 * app.curogram.com dashboard uses (cookie + XSRF, no API token).
 *
 * Env:
 *   CUROGRAM_AGENT_USERNAME / CUROGRAM_AGENT_PASSWORD  (login creds)
 *   MCP_BEARER_TOKEN                                   (gates HTTP transport)
 *   CUROGRAM_PRACTICE_ID (optional)                    (pin tenant per session)
 *   CUROGRAM_COOKIE / CUROGRAM_XSRF_TOKEN (optional)   (CDP harvest fallback)
 *
 * Transport: Streamable HTTP via mcp-shared on MCP_PORTS.curogram (18817).
 * The Tailscale funnel at /curogram terminates TLS and proxies to
 * 127.0.0.1:18817.
 *
 * SAFETY (Exult-specific):
 *   - curogram_send_text is OUT-OF-BAND APPROVAL-GATED. A boolean from the tool
 *     caller is NOT a real gate (the calling LLM can self-grant it), so the send
 *     requires a human-released, server-issued approval the LLM cannot produce:
 *       1. Called without an approval_id -> the server runs the fail-closed
 *          TCPA-consent + conversation/patient-linkage checks, then writes a
 *          pending-send record (with a secret, single-use, content-bound token)
 *          to a 0700 state dir and returns { status: "pending_approval", id }.
 *          It does NOT send. The token is never returned to the caller.
 *       2. A human (Gautam) confirms over iMessage; the orchestrator/operator
 *          reads the token from the 0700 record and writes the approval marker.
 *       3. Called again with that approval_id -> the server re-checks consent +
 *          linkage, validates the operator marker token + content hash + TTL +
 *          single-use, and only then performs the real send.
 *     See send-approval.ts and .claude/skills/curogram/references/send-approval.md
 *     for the full handshake contract.
 *   - TCPA: even an approved send is refused unless the patient's
 *     CommunicationPreferences show consent + allowSmsMessages.
 *   - PHI: message bodies and patient identifiers are NEVER logged; the pending
 *     record stores only a content hash, never the body.
 */

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";
import { authFromEnv, CUROGRAM_HOSTS, type CurogramAuth } from "./curogram-auth.ts";
import {
  createPendingSend,
  consumeApproval,
  contentHash,
} from "./send-approval.ts";

const auth: CurogramAuth = authFromEnv();

// ---------------------------------------------------------------------------
// GraphQL operation strings (lifted from the curogram skill's captured ops).
// ---------------------------------------------------------------------------
const GQL = {
  getConversationList:
    "query GetConversationList($skip: Int, $take: Int, $q: String, $unreadOnly: Boolean) {\n" +
    "  conversations(skip: $skip, take: $take, q: $q, unreadOnly: $unreadOnly) {\n" +
    "    totalItemCount\n" +
    "    items { id title type updatedAt unreadCount lastMessage { text statusUpdate } }\n" +
    "  }\n}",
  getPatientList:
    "query GetPatientList($skip: Int, $take: Int, $q: String) {\n" +
    "  patients(skip: $skip, take: $take, q: $q) {\n" +
    "    totalItemCount\n    items { id displayName dob }\n  }\n}",
  patientInfo:
    "query PatientInfo($id: ID!) {\n" +
    "  patient(id: $id) { id displayName dob }\n}",
  communicationPreferences:
    "query CommunicationPreferences($patientId: PatientId!) {\n" +
    "  communicationPreferences(patientId: $patientId) {\n" +
    "    allowCalls allowEmailMessages allowMarketingMessages allowSmsMessages consent\n" +
    "  }\n}",
} as const;

interface CommPrefs {
  allowCalls: boolean;
  allowEmailMessages: boolean;
  allowMarketingMessages: boolean;
  allowSmsMessages: boolean;
  consent: boolean;
}

/** Fetch a patient's comm/consent prefs from the patients microservice. */
async function getCommPrefs(patientId: string): Promise<CommPrefs> {
  const data = await auth.graphql<{ communicationPreferences: CommPrefs }>(
    CUROGRAM_HOSTS.patients,
    "CommunicationPreferences",
    GQL.communicationPreferences,
    { patientId },
  );
  return data.communicationPreferences;
}

type LinkResult =
  | { linked: true }
  | { linked: false; reason: string };

/**
 * Fail-closed check that `patientId` is actually a participant in
 * `conversationId`. Without this, a caller could pass a *consenting* patient's
 * id while sending into a *different* patient's thread — leaking PHI to the
 * wrong recipient. We confirm linkage via the conversation members endpoint
 * and refuse the send unless the patient is positively matched.
 */
async function verifyConversationPatientLink(
  conversationId: string,
  patientId: string,
): Promise<LinkResult> {
  let res: Response;
  try {
    res = await auth.fetch(
      `${CUROGRAM_HOSTS.apiV2}/conversations/${encodeURIComponent(conversationId)}/members`,
    );
  } catch (e) {
    return {
      linked: false,
      reason: `linkage check errored: ${e instanceof Error ? e.message : String(e)}`,
    };
  }
  if (!res.ok) {
    return { linked: false, reason: `members HTTP ${res.status}` };
  }
  // Fail closed if the body isn't valid JSON (e.g., an HTML error page from a
  // transient proxy/auth hiccup) rather than letting res.json() throw.
  let body: {
    items?: Array<Record<string, unknown>>;
    records?: Array<Record<string, unknown>>;
  };
  try {
    body = (await res.json()) as typeof body;
  } catch {
    return { linked: false, reason: "members response was not valid JSON" };
  }
  const members = body.items ?? body.records ?? [];
  // A member may carry the patient id under id / patientId / userId / a nested
  // patient.id depending on the shape; match against any of them.
  const matched = members.some((m) => {
    const nested = m.patient as Record<string, unknown> | undefined;
    const candidates = [m.id, m.patientId, m.userId, nested?.id];
    return candidates.some((c) => typeof c === "string" && c === patientId);
  });
  if (!matched) {
    // Fail-closed. If Curogram changes the members payload shape, this branch
    // is also where we land — note that so a shape drift is distinguishable
    // from a genuine mismatch during debugging.
    return {
      linked: false,
      reason:
        `patient_id is not a participant in conversation_id, or the members ` +
        `payload shape was unrecognized (got ${members.length} member record(s))`,
    };
  }
  return { linked: true };
}

/**
 * Build a fresh MCP Server per session (see rippling-mcp for the rationale:
 * the SDK's Protocol.connect refuses a second transport on one Server, so
 * concurrent clients each need their own instance). All shared state lives
 * in the module-scoped `auth` singleton.
 */
function buildServer(): Server {
  const server = new Server(
    { name: "curogram-mcp", version: "0.1.0" },
    { capabilities: { tools: {} } },
  );

  server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [
      // ---- Messaging ----
      {
        name: "curogram_unread_count",
        description: "Get the count of unread Curogram conversations (inbox badge).",
        inputSchema: { type: "object", properties: {} },
      },
      {
        name: "curogram_list_conversations",
        description:
          "List Curogram conversations (inbox). Returns id, title, type, unreadCount, and last message preview. Use unread_only to triage.",
        inputSchema: {
          type: "object",
          properties: {
            skip: { type: "number", description: "Pagination offset (default 0)" },
            take: { type: "number", description: "Page size (default 20, max 50)" },
            q: { type: "string", description: "Optional search query" },
            unread_only: { type: "boolean", description: "Only unread threads" },
          },
        },
      },
      {
        name: "curogram_read_thread",
        description:
          "Read messages in a conversation thread, newest-first paginated. Returns the message list for the given conversation id.",
        inputSchema: {
          type: "object",
          properties: {
            conversation_id: { type: "string", description: "Conversation ObjectId" },
            skip: { type: "number", description: "Pagination offset (default 0)" },
            take: { type: "number", description: "Page size (default 20, max 50)" },
          },
          required: ["conversation_id"],
        },
      },
      {
        name: "curogram_send_text",
        description:
          "Send a text message to a patient in a conversation. OUT-OF-BAND APPROVAL-GATED — a two-step human handshake the caller cannot self-grant. " +
          "STEP 1: call WITHOUT approval_id. The server verifies TCPA consent + conversation/patient linkage, then returns { status: \"pending_approval\", id, content_hash, expires_at } and does NOT send. " +
          "A human must then confirm out-of-band and the operator releases the approval. " +
          "STEP 2: call again with the SAME conversation_id / patient_id / message / send_securely PLUS approval_id from step 1. If the approval has been released and is still valid (matching content, unexpired, unused), the server sends. " +
          "patient_id is required in both steps (consent check + content binding). You cannot approve your own send.",
        inputSchema: {
          type: "object",
          properties: {
            conversation_id: { type: "string", description: "Conversation ObjectId to send into" },
            patient_id: {
              type: "string",
              description:
                "Patient ObjectId for the TCPA consent check and approval content-binding. Required.",
            },
            message: { type: "string", description: "Message text to send" },
            approval_id: {
              type: "string",
              description:
                "Approval id from a prior pending_approval response. Omit on the first (pending) call; provide on the live send. The caller cannot mint or guess this — it gates the real send behind an out-of-band human release.",
            },
            send_securely: {
              type: "boolean",
              description: "Send as a secure (link-gated) message. Default false.",
            },
          },
          required: ["conversation_id", "patient_id", "message"],
        },
      },
      {
        name: "curogram_mark_read",
        description:
          "Mark a conversation thread as read (whole thread, or up to a specific message id).",
        inputSchema: {
          type: "object",
          properties: {
            conversation_id: { type: "string", description: "Conversation ObjectId" },
            message_id: {
              type: "string",
              description: "Optional: mark read up to this message id. Omit to mark the whole thread.",
            },
          },
          required: ["conversation_id"],
        },
      },
      // ---- Patients (read-only) ----
      {
        name: "curogram_search_patients",
        description:
          "Search patients by name or phone. Returns id, displayName, dob. Use to resolve a patient before addressing or consent-checking a send.",
        inputSchema: {
          type: "object",
          properties: {
            q: { type: "string", description: "Search term (name or phone)" },
            skip: { type: "number", description: "Pagination offset (default 0)" },
            take: { type: "number", description: "Page size (default 20, max 50)" },
          },
          required: ["q"],
        },
      },
      {
        name: "curogram_get_patient",
        description: "Get a single patient's demographic record by id.",
        inputSchema: {
          type: "object",
          properties: {
            patient_id: { type: "string", description: "Patient ObjectId" },
          },
          required: ["patient_id"],
        },
      },
      {
        name: "curogram_get_comm_prefs",
        description:
          "Get a patient's communication preferences (consent, allowSmsMessages, allowCalls, allowEmailMessages, allowMarketingMessages). Check consent + allowSmsMessages before any outbound text (TCPA).",
        inputSchema: {
          type: "object",
          properties: {
            patient_id: { type: "string", description: "Patient ObjectId" },
          },
          required: ["patient_id"],
        },
      },
    ],
  }));

  server.setRequestHandler(CallToolRequestSchema, async (req) => {
    const { name, arguments: args } = req.params;
    try {
      switch (name) {
        case "curogram_unread_count": {
          const res = await auth.fetch(
            `${CUROGRAM_HOSTS.apiV2}/conversations/unread-count`,
          );
          if (!res.ok) return err(`unread-count HTTP ${res.status}`);
          return ok(await res.json());
        }

        case "curogram_list_conversations": {
          const data = await auth.graphql(
            CUROGRAM_HOSTS.apiV2,
            "GetConversationList",
            GQL.getConversationList,
            {
              skip: numArg(args, "skip", 0),
              take: clampTake(numArg(args, "take", 20)),
              q: optStr(args, "q"),
              unreadOnly: optBool(args, "unread_only"),
            },
          );
          return ok(data);
        }

        case "curogram_read_thread": {
          const convId = requireObjectId(args, "conversation_id");
          const skip = numArg(args, "skip", 0);
          const take = clampTake(numArg(args, "take", 20));
          const res = await auth.fetch(
            `${CUROGRAM_HOSTS.apiV2}/conversations/${encodeURIComponent(convId)}/messages?skip=${skip}&take=${take}`,
          );
          if (!res.ok) return err(`read-thread HTTP ${res.status}`);
          return ok(await res.json());
        }

        case "curogram_send_text":
          return await handleSendText(args);

        case "curogram_mark_read": {
          const convId = requireObjectId(args, "conversation_id");
          const msgId = optObjectId(args, "message_id");
          const path = msgId
            ? `/conversations/${encodeURIComponent(convId)}/messages/mark-read/${encodeURIComponent(msgId)}`
            : `/conversations/${encodeURIComponent(convId)}/messages/mark-read`;
          const res = await auth.fetch(`${CUROGRAM_HOSTS.apiV2}${path}`, {
            method: "POST",
          });
          if (!res.ok) return err(`mark-read HTTP ${res.status}`);
          return ok({ marked_read: true, conversation_id: convId });
        }

        case "curogram_search_patients": {
          const data = await auth.graphql(
            CUROGRAM_HOSTS.apiV2,
            "GetPatientList",
            GQL.getPatientList,
            {
              q: requireString(args, "q"),
              skip: numArg(args, "skip", 0),
              take: clampTake(numArg(args, "take", 20)),
            },
          );
          return ok(data);
        }

        case "curogram_get_patient": {
          const data = await auth.graphql(
            CUROGRAM_HOSTS.apiV2,
            "PatientInfo",
            GQL.patientInfo,
            { id: requireObjectId(args, "patient_id") },
          );
          return ok(data);
        }

        case "curogram_get_comm_prefs": {
          const prefs = await getCommPrefs(requireObjectId(args, "patient_id"));
          return ok(prefs);
        }

        default:
          return err(`Unknown tool: ${name}`);
      }
    } catch (e) {
      return err(e instanceof Error ? e.message : String(e));
    }
  });

  return server;
}

/**
 * Out-of-band approval-gated, consent-checked send.
 *
 * Flow:
 *   - First call (no approval_id): run the fail-closed TCPA + linkage checks,
 *     then mint a pending-send (server-side secret token, content hash) and
 *     return { status: "pending_approval" }. Never touches the send endpoint.
 *   - Second call (approval_id present): re-run the same fail-closed checks,
 *     then require a valid, human-released, content-bound, single-use approval
 *     before performing the real send.
 *
 * The consent + linkage gates run on BOTH calls: the first so we never create a
 * pending for a send that would be refused anyway, the second so a stale
 * approval can't ride past a consent that was revoked in between.
 */
async function handleSendText(
  args: Record<string, unknown> | undefined,
): Promise<ToolResult> {
  const convId = requireObjectId(args, "conversation_id");
  const message = requireString(args, "message");
  const patientId = requireObjectId(args, "patient_id");
  const sendSecurely = optBool(args, "send_securely") === true;
  const approvalId = optStr(args, "approval_id");

  // Gate 1: conversation/patient linkage. Refuse if the consent-checked patient
  // is not actually a participant in the target conversation, so a mismatched
  // id can never send PHI into the wrong patient's thread.
  const link = await verifyConversationPatientLink(convId, patientId);
  if (!link.linked) {
    return ok({
      sent: false,
      reason: "patient_conversation_mismatch",
      note: `Refusing to send: ${link.reason}. The patient_id used for the consent check must belong to conversation_id.`,
    });
  }

  // Gate 2: TCPA consent.
  let prefs: CommPrefs;
  try {
    prefs = await getCommPrefs(patientId);
  } catch (e) {
    return ok({
      sent: false,
      reason: "consent_check_failed",
      note: `Could not verify communication preferences: ${e instanceof Error ? e.message : String(e)}. Refusing to send.`,
    });
  }

  if (!prefs.consent || !prefs.allowSmsMessages) {
    return ok({
      sent: false,
      reason: "no_consent",
      note: "Patient has not consented to SMS (consent and/or allowSmsMessages is false). Refusing to send per TCPA.",
      prefs: { consent: prefs.consent, allowSmsMessages: prefs.allowSmsMessages },
    });
  }

  // Gate 3: out-of-band human approval.
  if (!approvalId) {
    // No approval yet -> create a pending-send request and return its id. The
    // server-side token and the content hash are written to the 0700 state dir;
    // the token is NOT returned. The send does not happen on this call.
    const pending = createPendingSend({
      conversationId: convId,
      patientId,
      message,
      sendSecurely,
    });
    return ok({
      sent: false,
      ...pending,
    });
  }

  // approval_id provided -> validate the human release. consumeApproval binds
  // the approval to this exact (conversation, patient, message, secure flag) via
  // the content hash, enforces TTL + single-use, and checks the operator marker
  // token. The tool caller cannot satisfy any of these by itself.
  const liveHash = contentHash({
    conversationId: convId,
    patientId,
    message,
    sendSecurely,
  });
  const approval = consumeApproval(approvalId, liveHash);
  if (!approval.ok) {
    return ok({
      sent: false,
      reason: "approval_invalid",
      approval_id: approvalId,
      note: `Refusing to send: ${approval.reason}. Approval must be released out-of-band by a human; the tool caller cannot grant it.`,
    });
  }

  // All gates passed (linkage, consent, valid human approval) -> send.
  const res = await auth.fetch(
    `${CUROGRAM_HOSTS.apiV2}/conversations/${encodeURIComponent(convId)}/messages`,
    {
      method: "POST",
      body: JSON.stringify({ message, sendSecurely }),
    },
  );
  if (!res.ok) return err(`send HTTP ${res.status}`);

  // x-frequency-warning: 5 means rate-limit hit -> surface so caller backs off.
  const freqWarning = res.headers.get("x-frequency-warning");
  const created = (await res.json()) as Record<string, unknown>;
  // Return only non-PHI confirmation fields rather than echoing the full API
  // object (which may carry the message text / recipient details).
  const createdObj =
    created && typeof created === "object" ? created : {};
  return ok({
    sent: true,
    conversation_id: convId,
    rate_limit_warning: freqWarning ?? null,
    message_id: createdObj.id ?? null,
    status: createdObj.status ?? createdObj.statusUpdate ?? null,
    created_at: createdObj.createdAt ?? createdObj.updatedAt ?? null,
  });
}

// ---------------------------------------------------------------------------
// Result + argument helpers (mirrors rippling-mcp conventions).
// ---------------------------------------------------------------------------
interface ToolResult {
  content: Array<{ type: "text"; text: string }>;
  isError?: boolean;
}

function ok(data: unknown): ToolResult {
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}

function err(msg: string): ToolResult {
  return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
}

function requireString(
  args: Record<string, unknown> | undefined,
  key: string,
): string {
  const v = args?.[key];
  if (typeof v !== "string" || v.length === 0) {
    throw new Error(`Missing required string argument: ${key}`);
  }
  return v;
}

/**
 * Curogram resource ids are 24-char Mongo ObjectIds (hex). Validate the shape
 * before interpolating into an API path: `encodeURIComponent` already prevents
 * traversal, but a strict check rejects garbage early with a clear error and
 * keeps malformed input off the wire.
 */
const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;

function requireObjectId(
  args: Record<string, unknown> | undefined,
  key: string,
): string {
  const v = requireString(args, key);
  if (!OBJECT_ID_RE.test(v)) {
    throw new Error(`Invalid ${key}: expected a 24-char hex ObjectId`);
  }
  return v;
}

/** Optional ObjectId — undefined when absent, validated when present. */
function optObjectId(
  args: Record<string, unknown> | undefined,
  key: string,
): string | undefined {
  const v = optStr(args, key);
  if (v === undefined) return undefined;
  if (!OBJECT_ID_RE.test(v)) {
    throw new Error(`Invalid ${key}: expected a 24-char hex ObjectId`);
  }
  return v;
}

function optStr(
  args: Record<string, unknown> | undefined,
  key: string,
): string | undefined {
  const v = args?.[key];
  return typeof v === "string" && v.length > 0 ? v : undefined;
}

function optBool(
  args: Record<string, unknown> | undefined,
  key: string,
): boolean | undefined {
  const v = args?.[key];
  return typeof v === "boolean" ? v : undefined;
}

function numArg(
  args: Record<string, unknown> | undefined,
  key: string,
  fallback: number,
): number {
  const v = args?.[key];
  if (typeof v !== "number" || !Number.isFinite(v)) return fallback;
  // skip/take feed GraphQL Int variables: coerce to a non-negative integer so
  // floats / negatives can't produce validation errors or invalid paging.
  const n = Math.floor(v);
  return n < 0 ? 0 : n;
}

/** Clamp page size to a sane ceiling. */
function clampTake(take: number): number {
  if (take <= 0) return 20;
  return Math.min(take, 50);
}

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

try {
  // serveMcpOverHttp binds the port synchronously (Bun.serve throws on
  // conflict), so reaching the next line means the listener is live.
  serveMcpOverHttp({
    serverFactory: buildServer,
    port: MCP_PORTS.curogram,
    token,
  });
  process.stderr.write(
    `curogram-mcp: server started on http :${MCP_PORTS.curogram} (v0.1.0)\n`,
  );
} catch (e) {
  const msg = e instanceof Error ? e.message : String(e);
  process.stderr.write(`curogram-mcp: fatal startup error: ${msg}\n`);
  process.exit(1);
}
