#!/usr/bin/env bun
/// <reference types="bun-types" />
/**
 * AdvancedMD EMR/PM MCP server.
 *
 * Two-step partner login: POST to partnerlogin URL → redirect → POST to
 * redirect URL → token. Token passed as Cookie header, NOT XML body.
 * XML login uses username=/psw= attributes (NOT usercode/userpassword).
 *
 * Env: AMD_OFFICE_KEY, AMD_USERNAME, AMD_PASSWORD, AMD_APPNAME
 * Write gate: AMD_ALLOW_WRITES=1 enables update_provider and raw_amd_call.
 *
 * Transport (Phase B, 2026-05-23): Streamable HTTP via mcp-shared on
 * MCP_PORTS.advancedmd (18812). Requires MCP_BEARER_TOKEN env. The Tailscale
 * funnel at /advancedmd terminates TLS and proxies to 127.0.0.1:18812.
 *
 * API status (2026-04-27):
 *   getdatevisits: working (payroll visit data)
 *   lookupproviders/getproviderinfo: requires provider module provisioning
 */

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";

type Credential = {
  label: string;
  username: string;
  password: string;
  officeKey: string;
  appname: string;
};

function loadCredentials(): Credential[] {
  const json = process.env.AMD_CREDENTIALS?.trim();
  if (json) {
    let parsed: unknown;
    try {
      parsed = JSON.parse(json);
    } catch (err) {
      process.stderr.write(`advancedmd-mcp: AMD_CREDENTIALS JSON parse failed: ${err}\n`);
      process.exit(1);
    }
    if (!Array.isArray(parsed) || parsed.length === 0) {
      process.stderr.write("advancedmd-mcp: AMD_CREDENTIALS must be a non-empty JSON array\n");
      process.exit(1);
    }
    return parsed.map((raw, i) => {
      if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
        process.stderr.write(
          `advancedmd-mcp: AMD_CREDENTIALS[${i}] must be an object, got ${typeof raw}\n`,
        );
        process.exit(1);
      }
      const c = raw as Record<string, unknown>;
      const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
      // Note on the appname default: the JSON path defaults to "ABS-AVMD"
      // (the registered consumer for office 161112; using "TEMP" here returns
      // PPMD api-app class-creation errors on data calls). The legacy path
      // below preserves "TEMP" as its default for back-compat with the prior
      // single-cred deploy; per-entry `appname` overrides take precedence in
      // both paths.
      //
      // Recommended: always set an explicit `label` per entry — it is what
      // surfaces in logs and the `auth_status` tool. If omitted it falls
      // back to `username`, which is the AMD account username and may leak
      // PII to whatever client calls the tool.
      const cred: Credential = {
        label: str(c.label) ?? str(c.username) ?? `cred${i}`,
        username: str(c.username) ?? "",
        password: str(c.password) ?? "",
        officeKey: str(c.officeKey) ?? str(c.office_key) ?? process.env.AMD_OFFICE_KEY ?? "",
        appname: str(c.appname) ?? "ABS-AVMD",
      };
      if (!cred.username || !cred.password || !cred.officeKey) {
        process.stderr.write(
          `advancedmd-mcp: AMD_CREDENTIALS[${i}] missing username/password/officeKey\n`,
        );
        process.exit(1);
      }
      return cred;
    });
  }
  // Legacy single-cred fallback (back-compat with old env).
  const office = process.env.AMD_OFFICE_KEY ?? "";
  const user = process.env.AMD_USERNAME ?? "";
  const pw = process.env.AMD_PASSWORD ?? "";
  const app = process.env.AMD_APPNAME ?? "TEMP";
  if (!office || !user || !pw) {
    process.stderr.write(
      "advancedmd-mcp: provide AMD_CREDENTIALS (JSON array) or AMD_OFFICE_KEY+AMD_USERNAME+AMD_PASSWORD\n",
    );
    process.exit(1);
  }
  return [{ label: user, username: user, password: pw, officeKey: office, appname: app }];
}

const CREDENTIALS = loadCredentials();
process.stderr.write(
  `advancedmd-mcp: loaded ${CREDENTIALS.length} credential(s): ${CREDENTIALS.map((c) => c.label).join(", ")}\n`,
);

// Fail closed when credentials span multiple officeKeys — rotation would
// otherwise silently return patient data from a different practice (serious
// PHI/HIPAA misconfiguration). Set AMD_ALLOW_MIXED_OFFICES=1 only if the
// deploy genuinely needs cross-office rotation and you've audited the
// consumers for office-aware downstream logic.
{
  const offices = new Set(CREDENTIALS.map((c) => c.officeKey));
  if (offices.size > 1) {
    const list = [...offices].join(", ");
    if (process.env.AMD_ALLOW_MIXED_OFFICES === "1") {
      process.stderr.write(
        `advancedmd-mcp: WARNING credentials span multiple officeKeys (${list}); AMD_ALLOW_MIXED_OFFICES=1 explicitly accepts the PHI rotation risk.\n`,
      );
    } else {
      process.stderr.write(
        `advancedmd-mcp: FATAL credentials span multiple officeKeys (${list}). Rotation between offices would silently switch patient data context. Use a single office, or set AMD_ALLOW_MIXED_OFFICES=1 if cross-office rotation is intentional.\n`,
      );
      process.exit(1);
    }
  }
}

const PARTNER_LOGIN_URL =
  "https://partnerlogin.advancedmd.com/practicemanager/xmlrpc/processrequest.aspx";

const RATE_COOLDOWN_MS = 60 * 60 * 1000; // 1 hr (matches AMD's per-hour login cap)
const AUTH_COOLDOWN_MS = 5 * 60 * 1000; // 5 min
const CLASS_COOLDOWN_MS = 60 * 60 * 1000; // 1 hr (class-creation is sticky)
// A locked account stays locked until an admin resets it in AdvancedMD. Retrying
// it on the short auth cooldown burns AMD's per-hour login cap (and can re-lock
// sibling accounts), so park it for a full day rather than 5 min.
const LOCK_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hr

let cachedToken: string | null = null;
let cachedWebServer: string | null = null;
let activeCredIndex = 0;
let activeCredLabel: string | null = null;
let lastLoginTs: number | null = null;
const credCooldowns = new Map<string, { until: number; reason: string }>();

function nowStr(): string {
  return new Date().toLocaleString("en-US");
}

function escapeXml(s: string): string {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}

function extractAttr(xml: string, tag: string, attr: string): string | null {
  const re = new RegExp(`<${tag}\\b[^>]*?\\b${attr}="([^"]*)"`, "i");
  const m = xml.match(re);
  return m ? m[1] : null;
}

function extractTag(xml: string, tag: string): string | null {
  const re = new RegExp(`<${tag}[^>]*>([^<]*)</${tag}>`, "i");
  const m = xml.match(re);
  return m ? m[1].trim() : null;
}

function extractElementText(xml: string, tag: string): string | null {
  const re = new RegExp(`<${tag}\\b[^>]*?>([^<]*)</${tag}>`, "is");
  const m = xml.match(re);
  return m ? m[1].trim() : null;
}

function extractAllAttrs(element: string): Record<string, string> {
  const result: Record<string, string> = {};
  const re = /(\w+)="([^"]*)"/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(element)) !== null) {
    result[m[1]] = m[2];
  }
  return result;
}

function extractRows(xml: string, tag: string): Record<string, string>[] {
  const rows: Record<string, string>[] = [];
  const re = new RegExp(`<${tag}\\b([^>]*?)\\s*/>|<${tag}\\b([^>]*?)>([\\s\\S]*?)</${tag}>`, "gi");
  let m: RegExpExecArray | null;
  while ((m = re.exec(xml)) !== null) {
    const attrStr = m[1] ?? m[2] ?? "";
    rows.push(extractAllAttrs(attrStr));
  }
  return rows;
}

function buildLoginXml(cred: Credential): string {
  return (
    `<ppmdmsg action="login" class="login" msgtime="${escapeXml(nowStr())}" ` +
    `username="${escapeXml(cred.username)}" psw="${escapeXml(cred.password)}" ` +
    `officecode="${escapeXml(cred.officeKey)}" appname="${escapeXml(cred.appname)}"/>`
  );
}

type ErrReason = "rate_limit" | "class_creation" | "auth_fail" | "locked" | "other";

function cooldownForReason(reason: ErrReason): number {
  switch (reason) {
    case "rate_limit":
      return RATE_COOLDOWN_MS;
    case "class_creation":
      return CLASS_COOLDOWN_MS;
    case "locked":
      return LOCK_COOLDOWN_MS;
    case "auth_fail":
      return AUTH_COOLDOWN_MS;
    case "other":
      return AUTH_COOLDOWN_MS;
  }
}

function classifyAmdResponse(text: string, status: number): ErrReason {
  const lower = text.toLowerCase();
  if (status === 429 || lower.includes("too many requests")) return "rate_limit";
  if (lower.includes("class creation error") || lower.includes("api-app class"))
    return "class_creation";
  // A locked account won't clear on its own — park it for a day (LOCK_COOLDOWN_MS)
  // instead of the 5-min auth cooldown. AMD's wording is "account has been locked";
  // match "account is locked" too for safety.
  if (lower.includes("account has been locked") || lower.includes("account is locked")) {
    return "locked";
  }
  if (
    status === 401 ||
    lower.includes("invalid username") ||
    lower.includes("invalid password") ||
    lower.includes("password expired")
  ) {
    return "auth_fail";
  }
  return "other";
}

/** Thrown for AMD-side errors that should mark the credential bad. */
class AmdLoginError extends Error {
  reason: ErrReason;
  constructor(msg: string, reason: ErrReason) {
    super(msg);
    this.reason = reason;
  }
}

/** Thrown for transport-level errors (DNS, timeout, TLS) — credential is not at fault. */
class AmdTransportError extends Error {
  constructor(msg: string, cause?: unknown) {
    // Use the standard ES2022 cause option so tooling that inspects Error.cause
    // (e.g. Node's util.inspect, structured loggers) picks it up natively.
    super(msg, cause === undefined ? undefined : { cause });
  }
}

/**
 * Two-step partner login for one credential:
 * 1. POST to partner login URL -> get redirect with webserver URL
 * 2. POST same body to webserver URL -> get usercontext token
 *
 * Error classification (step matters):
 *   step1 transport failure → AmdTransportError. Step 1 hits the same
 *     partnerlogin URL for every credential, so a transport failure is
 *     universal — bail out, do not penalize this or any other cred.
 *   step2 transport failure → AmdLoginError(reason="other"). Step 2 hits a
 *     webserver URL that is derived from {office, appname}. Credentials with
 *     different appnames may route to different webservers, so a transport
 *     failure here is cred-specific. Cooldown this cred and let the chain
 *     try the next one. (5-minute cooldown via "other".)
 *   AMD-side response without a token at either step → AmdLoginError with
 *     reason from classifyAmdResponse.
 */
async function loginCred(cred: Credential): Promise<{ webServer: string; token: string }> {
  const body = buildLoginXml(cred);

  let res1: Response;
  let text1: string;
  try {
    res1 = await fetch(PARTNER_LOGIN_URL, {
      method: "POST",
      headers: { "Content-Type": "text/xml" },
      body,
    });
    text1 = await res1.text();
  } catch (err) {
    // Universal failure — same URL for every credential, no point trying others.
    throw new AmdTransportError(`step1 transport failure: ${err}`, err);
  }
  process.stderr.write(`advancedmd-mcp: [${cred.label}] step1 status=${res1.status}\n`);

  let webServer = extractAttr(text1, "usercontext", "webserver");
  if (!webServer) {
    const desc = extractTag(text1, "description") ?? "";
    const wsMatch = desc.match(/https:\/\/[^\s.]+\.advancedmd\.com\/processrequest\/[^\s"<]+/);
    if (wsMatch) webServer = wsMatch[0];
  }
  if (!webServer) {
    throw new AmdLoginError(
      `step1 no webserver: ${text1.slice(0, 300)}`,
      classifyAmdResponse(text1, res1.status),
    );
  }

  const apiUrl = `${webServer}/xmlrpc/processrequest.aspx`;
  let res2: Response;
  let text2: string;
  try {
    res2 = await fetch(apiUrl, {
      method: "POST",
      headers: { "Content-Type": "text/xml" },
      body,
    });
    text2 = await res2.text();
  } catch (err) {
    // Cred-specific failure — webserver URL is derived from {office, appname}.
    // Cooldown this cred and let the chain try the next one.
    throw new AmdLoginError(`step2 transport failure: ${err}`, "other");
  }
  process.stderr.write(`advancedmd-mcp: [${cred.label}] step2 status=${res2.status}\n`);

  const token = extractElementText(text2, "usercontext");
  if (!token) {
    const errDesc =
      extractTag(text2, "description") ?? extractTag(text2, "faultstring") ?? "unknown";
    throw new AmdLoginError(
      `step2 no token: ${errDesc}. body=${text2.slice(0, 300)}`,
      classifyAmdResponse(text2, res2.status),
    );
  }
  return { webServer, token };
}

/**
 * Walk the credential chain. Start at activeCredIndex, skip cooldown'd creds,
 * return first that logs in. AMD-side failures cooldown the cred and move on.
 * Transport-level failures (DNS/timeout/TLS) propagate without penalizing any
 * cred — a network blip should not burn the whole chain.
 */
async function loginInner(): Promise<{ webServer: string; token: string }> {
  const now = Date.now();
  const errors: string[] = [];
  for (let attempt = 0; attempt < CREDENTIALS.length; attempt++) {
    const idx = (activeCredIndex + attempt) % CREDENTIALS.length;
    const cred = CREDENTIALS[idx];
    const cd = credCooldowns.get(cred.label);
    if (cd && cd.until > now) {
      errors.push(`${cred.label}: cooldown ${Math.ceil((cd.until - now) / 60000)}m (${cd.reason})`);
      continue;
    }
    try {
      const sess = await loginCred(cred);
      activeCredIndex = idx;
      activeCredLabel = cred.label;
      cachedWebServer = sess.webServer;
      cachedToken = sess.token;
      lastLoginTs = Date.now();
      credCooldowns.delete(cred.label);
      process.stderr.write(`advancedmd-mcp: login SUCCESS [${cred.label}]\n`);
      return sess;
    } catch (err) {
      if (err instanceof AmdTransportError) {
        // Transport failure — do NOT cooldown the cred, just bubble up.
        process.stderr.write(
          `advancedmd-mcp: login TRANSPORT-ERR [${cred.label}] ${err.message.slice(0, 200)}\n`,
        );
        throw err;
      }
      const reason: ErrReason = err instanceof AmdLoginError ? err.reason : "other";
      const cooldownMs = cooldownForReason(reason);
      credCooldowns.set(cred.label, { until: Date.now() + cooldownMs, reason });
      const msg = err instanceof Error ? err.message.slice(0, 200) : String(err);
      errors.push(`${cred.label}: ${reason} (${msg})`);
      process.stderr.write(
        `advancedmd-mcp: login FAIL [${cred.label}] ${reason} cooldown=${cooldownMs / 60000}m\n`,
      );
    }
  }
  throw new Error(`All ${CREDENTIALS.length} credentials failed: ${errors.join(" | ")}`);
}

// Single-flight: parallel callers wait on the in-progress login instead of
// kicking off duplicate chains that thrash cooldowns.
let loginInFlight: Promise<{ webServer: string; token: string }> | null = null;
async function login(): Promise<{ webServer: string; token: string }> {
  if (loginInFlight) return loginInFlight;
  loginInFlight = loginInner().finally(() => {
    loginInFlight = null;
  });
  return loginInFlight;
}

/**
 * Penalize a specific credential by label. Callers must capture the label of
 * the credential they were USING when the error happened (snapshot before the
 * await), not read `activeCredLabel` after — concurrent flows may have
 * rotated the active credential in between, which would penalize an innocent
 * cred.
 */
function markCredBad(label: string | null, reason: ErrReason): void {
  if (!label) return;
  const cooldownMs = cooldownForReason(reason);
  credCooldowns.set(label, { until: Date.now() + cooldownMs, reason });
  process.stderr.write(
    `advancedmd-mcp: marking [${label}] cooldown ${cooldownMs / 60000}m (${reason})\n`,
  );
}

async function ensureSession(): Promise<{ webServer: string; token: string }> {
  if (cachedWebServer && cachedToken) {
    return { webServer: cachedWebServer, token: cachedToken };
  }
  return login();
}

/** Session-expired patterns (token rotation, not a credential fault). */
function isSessionExpired(text: string, status: number): boolean {
  if (status === 401) return true;
  const lower = text.toLowerCase();
  return (
    lower.includes("session expired") ||
    lower.includes("invalid session") ||
    lower.includes("not authenticated") ||
    lower.includes("security token was expected")
  );
}

/**
 * Issue a single XMLRPC POST against the current session. Returns
 * {res, text, credAtIssue} so the caller can classify and decide whether to
 * rotate. The cred label is snapshotted before the await so any post-error
 * cooldown penalizes the credential that actually served this call (rather
 * than whatever happens to be active by the time we recognize the error).
 */
async function attemptCall(
  action: string,
  attrs: string,
  children: string,
  cls: string,
): Promise<{ text: string; status: number; ok: boolean; credAtIssue: string | null }> {
  const { webServer, token } = await ensureSession();
  const credAtIssue = activeCredLabel;
  const apiUrl = `${webServer}/xmlrpc/processrequest.aspx`;
  const body =
    `<ppmdmsg action="${escapeXml(action)}" class="${escapeXml(cls)}" msgtime="${escapeXml(nowStr())}" ${attrs}>` +
    children +
    `</ppmdmsg>`;
  const res = await fetch(apiUrl, {
    method: "POST",
    headers: { "Content-Type": "text/xml", Cookie: `token=${token}` },
    body,
  });
  const text = await res.text();
  return { text, status: res.status, ok: res.ok, credAtIssue };
}

async function apiCall(
  action: string,
  attrs: string = "",
  children: string = "",
  cls: string = "api",
): Promise<string> {
  // Try once per credential in the chain. Each attempt that returns a
  // rate-limit/class-creation/session-expired response triggers cred rotation
  // (and cooldown of the offending cred for the AMD-side cases) and re-tries
  // with the next credential. Without this loop, a chain failure would return
  // junk to the caller and leave a broken cred as "active".
  const maxAttempts = Math.max(CREDENTIALS.length, 1);
  let last: { text: string; status: number; ok: boolean; credAtIssue: string | null } | null =
    null;
  for (let i = 0; i < maxAttempts; i++) {
    const result = await attemptCall(action, attrs, children, cls);
    last = result;

    const expired = isSessionExpired(result.text, result.status);
    const reason = expired ? null : classifyAmdResponse(result.text, result.status);
    const needsRotation =
      expired || reason === "rate_limit" || reason === "class_creation" || reason === "locked";

    if (!needsRotation) {
      if (!result.ok) {
        throw new Error(`API ${action} HTTP ${result.status}: ${result.text.slice(0, 500)}`);
      }
      return result.text;
    }

    // Penalize when the failure is the credential's fault. Plain session
    // expiry is normal token rotation and not a cred problem.
    if (reason === "rate_limit" || reason === "class_creation" || reason === "locked") {
      markCredBad(result.credAtIssue, reason);
    }
    process.stderr.write(
      `advancedmd-mcp: apiCall ${action} attempt ${i + 1}/${maxAttempts} got ${
        reason ?? "expired"
      } on [${result.credAtIssue ?? "unknown"}]; rotating\n`,
    );
    cachedWebServer = null;
    cachedToken = null;
    // Next loop iteration will call ensureSession() → login(), which walks
    // the chain skipping cooldown'd creds.
  }

  // Every credential exhausted and still classifying as needs-rotation.
  throw new Error(
    `API ${action} failed: every credential returned rate_limit / class_creation / expired across ${maxAttempts} attempt(s). Last status=${last?.status} body=${(last?.text ?? "").slice(0, 300)}`,
  );
}

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

function parseResponse(xml: string): Record<string, string>[] {
  const tags = ["visit", "patient", "row", "appointment", "note", "record", "item", "fieldset", "patientnote", "page", "provider", "providerinfo", "user", "staff", "field"];
  const allRows: Record<string, Record<string, string>[]> = {};
  for (const tag of tags) {
    const rows = extractRows(xml, tag);
    if (rows.length > 0) allRows[tag] = rows;
  }

  const tagNames = Object.keys(allRows);
  if (tagNames.length === 0) return [];
  if (tagNames.length === 1) return allRows[tagNames[0]];

  // Multiple tag types: merge by index, prefixing secondary tags
  const primary = tagNames[0];
  const primaryRows = allRows[primary];
  const maxLen = Math.max(...Object.values(allRows).map((r) => r.length));

  const merged: Record<string, string>[] = [];
  for (let i = 0; i < maxLen; i++) {
    const row: Record<string, string> = { ...(primaryRows[i] ?? {}) };
    for (const tag of tagNames.slice(1)) {
      const secondary = allRows[tag][i];
      if (secondary) {
        for (const [k, v] of Object.entries(secondary)) {
          row[`${tag}_${k}`] = v;
        }
      }
    }
    merged.push(row);
  }
  return merged;
}

const TOOLS = [
  {
    name: "login",
    description:
      "Authenticate with AdvancedMD and obtain a session. Called automatically when needed, but can be invoked explicitly to test credentials.",
    inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
  },
  {
    name: "auth_status",
    description:
      "Inspect credential-chain state: which credential is currently active, when it last logged in, and cooldown state for each.",
    inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
  },
  {
    name: "get_updated_patients",
    description: "Get patients modified since a given date.",
    inputSchema: {
      type: "object" as const,
      properties: {
        lastmodifieddate: { type: "string", description: "Date in YYYY-MM-DD format" },
      },
      required: ["lastmodifieddate"],
    },
  },
  {
    name: "get_updated_visits",
    description: "Get visits modified since a given date.",
    inputSchema: {
      type: "object" as const,
      properties: {
        lastmodifieddate: { type: "string", description: "Date in YYYY-MM-DD format" },
      },
      required: ["lastmodifieddate"],
    },
  },
  {
    name: "get_visit_info_by_date",
    description: "Get all visits for a specific date.",
    inputSchema: {
      type: "object" as const,
      properties: {
        visitdate: { type: "string", description: "Date in YYYY-MM-DD format" },
      },
      required: ["visitdate"],
    },
  },
  {
    name: "get_appointment_history",
    description: "Get appointment history for a specific patient.",
    inputSchema: {
      type: "object" as const,
      properties: {
        patientid: { type: "string", description: "Patient ID" },
      },
      required: ["patientid"],
    },
  },
  {
    name: "get_ehr_updated_notes",
    description: "Get clinical notes modified since a given date.",
    inputSchema: {
      type: "object" as const,
      properties: {
        lastmodifieddate: { type: "string", description: "Date in YYYY-MM-DD format" },
      },
      required: ["lastmodifieddate"],
    },
  },
  {
    name: "get_fieldset_info",
    description: "Get metadata about available fields in AdvancedMD.",
    inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
  },
  {
    name: "search_patients",
    description: "Search for patients by name or date of birth.",
    inputSchema: {
      type: "object" as const,
      properties: {
        searchterm: { type: "string", description: "Patient name or DOB to search for" },
      },
      required: ["searchterm"],
    },
  },
  {
    name: "list_providers",
    description:
      "List all providers (clinicians) in the practice. Returns name, internal provider ID, NPI, taxonomy, and other configured fields. Used for credentialing/staff exports.",
    inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
  },
  {
    name: "get_provider_details",
    description:
      "Fetch full provider record (NPI, taxonomy, license, DEA, specialty, etc.) for a single provider. Use list_providers to discover IDs.",
    inputSchema: {
      type: "object" as const,
      properties: {
        providerid: { type: "string", description: "Provider ID from list_providers" },
      },
      required: ["providerid"],
    },
  },
  {
    name: "update_provider",
    description:
      "Update fields on a provider record. Gated by AMD_ALLOW_WRITES=1 env var; returns an error otherwise. Pass providerid plus a JSON object of field=value pairs in 'updates'.",
    inputSchema: {
      type: "object" as const,
      properties: {
        providerid: { type: "string", description: "Provider ID to update" },
        updates: {
          type: "string",
          description: "JSON-encoded object of field name -> value pairs to set",
        },
      },
      required: ["providerid", "updates"],
    },
  },
  {
    name: "raw_amd_call",
    description:
      "Escape hatch: invoke an arbitrary ppmdmsg action with custom attrs and children XML. Use only for API exploration when canonical tools do not cover the use case. Returns raw XML response.",
    inputSchema: {
      type: "object" as const,
      properties: {
        action: { type: "string", description: "ppmdmsg action name (e.g. getproviderlist)" },
        attrs: {
          type: "string",
          description: "Additional XML attributes for the ppmdmsg root (e.g. providerid=\"123\")",
        },
        children: {
          type: "string",
          description: "Inner XML child elements (e.g. field templates)",
        },
        cls: {
          type: "string",
          description: "ppmdmsg class attribute, defaults to 'api' (other valid: 'provider', 'patient')",
        },
      },
      required: ["action"],
    },
  },
];

async function handleTool(
  name: string,
  args: Record<string, string>,
): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
  try {
    let rawXml: string;

    switch (name) {
      case "login": {
        const session = await login();
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify({
                ok: true,
                webServer: session.webServer,
                activeCredential: activeCredLabel,
              }),
            },
          ],
        };
      }
      case "auth_status": {
        const now = Date.now();
        const cooldowns: Record<string, { reason: string; minsLeft: number }> = {};
        for (const [label, cd] of credCooldowns.entries()) {
          if (cd.until > now) {
            cooldowns[label] = {
              reason: cd.reason,
              minsLeft: Math.ceil((cd.until - now) / 60000),
            };
          }
        }
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(
                {
                  credentials: CREDENTIALS.map((c) => ({ label: c.label, appname: c.appname })),
                  activeCredential: activeCredLabel,
                  activeIndex: activeCredIndex,
                  lastLoginAt: lastLoginTs ? new Date(lastLoginTs).toISOString() : null,
                  cooldowns,
                },
                null,
                2,
              ),
            },
          ],
        };
      }
      case "get_updated_patients":
        rawXml = await apiCall(
          "getupdatedpatients",
          `datechanged="${escapeXml(args.lastmodifieddate)}"`,
          `<patient name="Name" ssn="SSN" changedat="ChangedAt" createdat="CreatedAt"/>`,
        );
        break;
      case "get_updated_visits":
        rawXml = await apiCall(
          "getupdatedvisits",
          `datechanged="${escapeXml(args.lastmodifieddate)}"`,
          `<visit date="VisitDate" duration="Duration" color="Color" apptstatus="ApptStatus" columnheading="ColumnHeading"/><patient name="Name" dob="DOB"/>`,
        );
        break;
      case "get_visit_info_by_date":
        rawXml = await apiCall(
          "getdatevisits",
          `visitdate="${escapeXml(args.visitdate)}"`,
          `<visit columnheading="ColumnHeading" duration="Duration" color="Color" apptstatus="ApptStatus"/><patient name="Name" changedat="ChangedAt" createdat="CreatedAt"/>`,
        );
        break;
      case "get_appointment_history":
        rawXml = await apiCall(
          "getpatientvisits",
          `patientid="${escapeXml(args.patientid)}"`,
          `<visit date="VisitDate" duration="Duration" apptstatus="ApptStatus" columnheading="ColumnHeading"/>`,
        );
        break;
      case "get_ehr_updated_notes":
        rawXml = await apiCall(
          "getehrupdatednotes",
          `datechanged="${escapeXml(args.lastmodifieddate)}"`,
        );
        break;
      case "get_fieldset_info":
        rawXml = await apiCall("getupdatedvisitstemplate");
        break;
      case "search_patients":
        rawXml = await apiCall(
          "getpatientlist",
          `searchterm="${escapeXml(args.searchterm)}"`,
          `<patient name="Name" dob="DOB" ssn="SSN" changedat="ChangedAt" createdat="CreatedAt"/>`,
        );
        break;
      case "list_providers":
        rawXml = await apiCall(
          "lookupproviders",
          "",
          `<provider id="ID" code="Code" name="Name" lastname="LastName" firstname="FirstName" npi="NPI" taxonomycode="TaxonomyCode" specialty="Specialty" licensenumber="LicenseNumber" licensestate="LicenseState" deanumber="DEANumber" active="Active"/>`,
        );
        break;
      case "get_provider_details":
        rawXml = await apiCall(
          "getproviderinfo",
          `providerid="${escapeXml(args.providerid)}"`,
        );
        break;
      case "update_provider": {
        if (!ALLOW_WRITES) {
          return {
            content: [
              {
                type: "text",
                text: "update_provider is disabled. Set AMD_ALLOW_WRITES=1 in the MCP server env to enable.",
              },
            ],
            isError: true,
          };
        }
        let updates: Record<string, string>;
        try {
          updates = JSON.parse(args.updates);
        } catch (e) {
          return {
            content: [{ type: "text", text: `Invalid JSON in 'updates': ${e}` }],
            isError: true,
          };
        }
        const SAFE_ATTR_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
        for (const k of Object.keys(updates)) {
          if (!SAFE_ATTR_NAME.test(k)) {
            return {
              content: [{ type: "text", text: `Invalid attribute name: ${k}` }],
              isError: true,
            };
          }
        }
        const updateAttrs = Object.entries(updates)
          .map(([k, v]) => `${k}="${escapeXml(String(v))}"`)
          .join(" ");
        rawXml = await apiCall(
          "saveproviderinfo",
          `providerid="${escapeXml(args.providerid)}" ${updateAttrs}`,
        );
        process.stderr.write(
          `advancedmd-mcp: update_provider id=${escapeXml(args.providerid)} fields=${Object.keys(updates).join(",")}\n`,
        );
        break;
      }
      case "raw_amd_call":
        if (!ALLOW_WRITES) {
          return {
            content: [
              {
                type: "text",
                text: "raw_amd_call is disabled. Set AMD_ALLOW_WRITES=1 in the MCP server env to enable.",
              },
            ],
            isError: true,
          };
        }
        process.stderr.write(
          `advancedmd-mcp: raw_amd_call action=${args.action} cls=${args.cls || "api"}\n`,
        );
        rawXml = await apiCall(
          args.action,
          args.attrs ?? "",
          args.children ?? "",
          args.cls || "api",
        );
        break;
      default:
        return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
    }

    const rows = parseResponse(rawXml);
    const count = rows.length;
    const resultText =
      count > 0
        ? JSON.stringify({ count, rows }, null, 2)
        : JSON.stringify({ count: 0, rows: [], rawSnippet: rawXml.slice(0, 1000) }, null, 2);

    return { content: [{ type: "text", text: resultText }] };
  } catch (err: unknown) {
    const msg = err instanceof Error ? err.message : String(err);
    return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
  }
}

/**
 * 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.
 *
 * All AdvancedMD state (session token cache, ppmdmsg client) lives at
 * module scope and is shared across sessions via the closure -- nothing
 * here is per-Server.
 */
function buildServer(): Server {
  const server = new Server(
    { name: "advancedmd", version: "0.4.0" },
    { capabilities: { tools: {} } },
  );

  server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: TOOLS,
  }));

  server.setRequestHandler(CallToolRequestSchema, async (req) => {
    const { name, arguments: args } = req.params;
    return handleTool(name, (args ?? {}) as Record<string, string>);
  });

  return server;
}

const token = process.env.MCP_BEARER_TOKEN;
if (!token) {
  process.stderr.write("advancedmd-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.advancedmd,
    token,
  });
} catch (err) {
  const msg = err instanceof Error ? err.message : String(err);
  process.stderr.write(`advancedmd-mcp: fatal startup error: ${msg}\n`);
  process.exit(1);
}

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