#!/usr/bin/env bun
/**
 * AMD New Patient Notifier — bun port of the Cloudflare Worker.
 *
 * Polls AdvancedMD every ~5 minutes (via systemd timer) for new patients and
 * sends a Teams notification via the teams-channel bun server's compat
 * /api/notify endpoint.
 *
 * State (last poll date, seen patient IDs) is stored as JSON on local disk
 * at AMD_NOTIFY_STATE_FILE (default /home/claude/state/amd-notify.json).
 *
 * Env vars (loaded from /home/claude/.config/amd-notify.env by systemd unit):
 *   AMD_OFFICE_KEY, AMD_USERNAME, AMD_PASSWORD, AMD_APPNAME
 *   TEAMS_WORKER_URL  - base URL of teams-channel bun (e.g. https://claude-cloud.tail053faf.ts.net/teams)
 *   TEAMS_API_KEY     - shared secret for the /api/notify endpoint
 *   TEAMS_CHAT_ID     - target chat / conversation id
 *   AMD_NOTIFY_STATE_FILE (optional override)
 *   AMD_NOTIFY_MOCK   - if "1", skip the real AMD calls and emit a test patient.
 */

import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

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

const STATE_FILE =
  process.env.AMD_NOTIFY_STATE_FILE ?? "/home/claude/state/amd-notify.json";

const MOCK_MODE = process.env.AMD_NOTIFY_MOCK === "1";

// ---------------------------------------------------------------------------
// State (JSON on local disk; replaces CF KV)
// ---------------------------------------------------------------------------

interface PollerState {
  lastPollDate?: string;
  seenPatientIds?: string[];
}

function loadState(): PollerState {
  try {
    if (!existsSync(STATE_FILE)) return {};
    const raw = readFileSync(STATE_FILE, "utf-8");
    return JSON.parse(raw) as PollerState;
  } catch (err) {
    process.stderr.write(`amd-notify: failed to load state: ${String(err)}\n`);
    return {};
  }
}

function saveState(state: PollerState): void {
  const dir = dirname(STATE_FILE);
  mkdirSync(dir, { recursive: true, mode: 0o700 });
  // Atomic write: stage to .tmp then rename. Prevents corruption if the
  // process is interrupted (systemd stop, OOM, etc) mid-write.
  const tmp = `${STATE_FILE}.tmp`;
  writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
  renameSync(tmp, STATE_FILE);
}

// ---------------------------------------------------------------------------
// XML helpers
// ---------------------------------------------------------------------------

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

function nowStr(): string {
  const d = new Date();
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}

function formatDate(d: Date): string {
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}

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

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

function extractAttrFromTag(tag: string, attr: string): string | null {
  // Require an attribute-name boundary (whitespace or tag opener) before
  // `attr=` so that requesting `id` does NOT match `providerid="..."` or
  // `patientid="..."`. Without the boundary, the first substring match wins,
  // which silently returns the wrong attribute value.
  const re = new RegExp(`(?:^|[\\s<])${attr}="([^"]*)"`, "i");
  const m = tag.match(re);
  return m ? m[1] : null;
}

// ---------------------------------------------------------------------------
// AMD API
// ---------------------------------------------------------------------------

interface AmdEnv {
  AMD_OFFICE_KEY: string;
  AMD_USERNAME: string;
  AMD_PASSWORD: string;
  AMD_APPNAME: string;
}

let cachedSession: { webServer: string; token: string } | null = null;

async function amdLogin(env: AmdEnv): Promise<{ webServer: string; token: string }> {
  const loginXml =
    `<ppmdmsg action="login" class="login" msgtime="${escapeXml(nowStr())}" ` +
    `username="${escapeXml(env.AMD_USERNAME)}" psw="${escapeXml(env.AMD_PASSWORD)}" ` +
    `officecode="${escapeXml(env.AMD_OFFICE_KEY)}" appname="${escapeXml(env.AMD_APPNAME)}"/>`;

  const res1 = await fetch(PARTNER_LOGIN_URL, {
    method: "POST",
    headers: { "Content-Type": "text/xml" },
    body: loginXml,
  });
  const text1 = await res1.text();

  let webServer = extractAttr(text1, "usercontext", "webserver");
  if (!webServer) {
    const desc = text1.match(/https:\/\/[^\s.]+\.advancedmd\.com\/processrequest\/[^\s"<]+/);
    if (desc) webServer = desc[0];
  }
  if (!webServer) throw new Error(`AMD login step 1 failed: ${text1.slice(0, 300)}`);

  const apiUrl = `${webServer}/xmlrpc/processrequest.aspx`;
  const res2 = await fetch(apiUrl, {
    method: "POST",
    headers: { "Content-Type": "text/xml" },
    body: loginXml,
  });
  const text2 = await res2.text();

  const token = extractElementText(text2, "usercontext");
  if (!token) throw new Error(`AMD login step 2 failed: ${text2.slice(0, 300)}`);

  cachedSession = { webServer, token };
  return cachedSession;
}

// AMD wraps API errors in a <results><resultcode>1</resultcode><resultmsg>...
// </resultmsg></results> envelope (resultcode == 0 == success). Without
// inspecting this, an empty patient list from an upstream error is
// indistinguishable from a real "no new patients" response, which would
// silently advance the lastPollDate cursor and skip a window of patients.
function assertAmdSuccess(action: string, text: string): void {
  const code = extractElementText(text, "resultcode");
  if (code !== null && code !== "0") {
    const msg = extractElementText(text, "resultmsg") ?? "(no resultmsg)";
    throw new Error(
      `AMD ${action} returned error envelope (resultcode=${code}): ${msg.slice(0, 300)}`,
    );
  }
}

async function amdApiCall(
  env: AmdEnv,
  action: string,
  attrs: string,
  children: string,
): Promise<string> {
  if (!cachedSession) await amdLogin(env);
  const { webServer, token } = cachedSession!;

  const body =
    `<ppmdmsg action="${escapeXml(action)}" class="api" msgtime="${escapeXml(nowStr())}" ${attrs}>` +
    children +
    `</ppmdmsg>`;

  const res = await fetch(`${webServer}/xmlrpc/processrequest.aspx`, {
    method: "POST",
    headers: { "Content-Type": "text/xml", Cookie: `token=${token}` },
    body,
  });
  const text = await res.text();

  if (
    res.status === 401 ||
    text.toLowerCase().includes("session expired") ||
    text.toLowerCase().includes("not authenticated")
  ) {
    cachedSession = null;
    await amdLogin(env);
    const retry = await fetch(`${cachedSession!.webServer}/xmlrpc/processrequest.aspx`, {
      method: "POST",
      headers: { "Content-Type": "text/xml", Cookie: `token=${cachedSession!.token}` },
      body,
    });
    if (!retry.ok) {
      const body = await retry.text().catch(() => "(no body)");
      throw new Error(
        `AMD ${action} retry HTTP ${retry.status}: ${body.slice(0, 300)}`,
      );
    }
    const retryText = await retry.text();
    assertAmdSuccess(action, retryText);
    return retryText;
  }

  // Any non-2xx (other than the 401 reauth case handled above) means the
  // request never reached AMD's app layer (CDN 502, gateway 503, etc). Treat
  // as a hard failure so the caller does not advance the poll cursor.
  if (!res.ok) {
    throw new Error(
      `AMD ${action} HTTP ${res.status}: ${text.slice(0, 300)}`,
    );
  }

  assertAmdSuccess(action, text);
  return text;
}

// ---------------------------------------------------------------------------
// Patient parsing & enrichment
// ---------------------------------------------------------------------------

interface PatientRecord {
  id: string;
  name: string;
  createdAt: string;
  changedAt: string;
  nextAppointment?: string;
  nextApptProvider?: string;
  nextApptType?: string;
  referringProvider?: string;
}

function parsePatients(xml: string): PatientRecord[] {
  const patients: PatientRecord[] = [];
  const re = /<patient\s[^>]*>/gi;
  let match;
  while ((match = re.exec(xml)) !== null) {
    const tag = match[0];
    const id = extractAttrFromTag(tag, "id") ?? extractAttrFromTag(tag, "patientid") ?? "";
    const name = extractAttrFromTag(tag, "name") ?? "";
    const createdAt = extractAttrFromTag(tag, "createdat") ?? "";
    const changedAt = extractAttrFromTag(tag, "changedat") ?? "";
    if (id && name) {
      patients.push({ id, name, createdAt, changedAt });
    }
  }
  return patients;
}

async function getNextAppointment(
  env: AmdEnv,
  patientId: string,
): Promise<{ date: string; provider: string; type: string } | null> {
  try {
    const xml = await amdApiCall(
      env,
      "getpatientvisits",
      `patientid="${escapeXml(patientId)}"`,
      `<visit date="VisitDate" duration="Duration" apptstatus="ApptStatus" columnheading="ColumnHeading"/>`,
    );
    const today = formatDate(new Date());
    const visits: Array<{ date: string; provider: string; type: string; status: string }> = [];
    const visitRe = /<visit\s[^>]*>/gi;
    let m;
    while ((m = visitRe.exec(xml)) !== null) {
      const tag = m[0];
      const date = extractAttrFromTag(tag, "visitdate") ?? extractAttrFromTag(tag, "date") ?? "";
      const provider = extractAttrFromTag(tag, "columnheading") ?? "";
      const type = extractAttrFromTag(tag, "duration") ?? "";
      const status = extractAttrFromTag(tag, "apptstatus") ?? "";
      if (date >= today && status !== "Cancelled") {
        visits.push({ date, provider, type, status });
      }
    }
    visits.sort((a, b) => a.date.localeCompare(b.date));
    return visits[0] ?? null;
  } catch {
    return null;
  }
}

async function getReferringProvider(env: AmdEnv, patientId: string): Promise<string | null> {
  try {
    const xml = await amdApiCall(
      env,
      "getpatientdemographic",
      `patientid="${escapeXml(patientId)}"`,
      "",
    );
    const refProvider =
      extractAttr(xml, "referringprovider", "name") ??
      extractAttr(xml, "patient", "referringprovidername") ??
      extractAttr(xml, "demographic", "referringprovider") ??
      extractAttrFromTag(xml, "referringprovidername");
    return refProvider || null;
  } catch {
    return null;
  }
}

async function enrichPatients(env: AmdEnv, patients: PatientRecord[]): Promise<void> {
  for (const p of patients) {
    const [appt, refProvider] = await Promise.all([
      getNextAppointment(env, p.id),
      getReferringProvider(env, p.id),
    ]);
    if (appt) {
      p.nextAppointment = appt.date;
      p.nextApptProvider = appt.provider;
      p.nextApptType = appt.type;
    }
    if (refProvider) {
      p.referringProvider = refProvider;
    }
  }
}

// ---------------------------------------------------------------------------
// Teams notification (calls teams-channel bun /api/notify compat endpoint)
// ---------------------------------------------------------------------------

async function sendTeamsNotification(
  teamsWorkerUrl: string,
  teamsApiKey: string,
  teamsChatId: string,
  patients: PatientRecord[],
): Promise<void> {
  const lines = patients.map((p) => {
    let line = `- ${p.name} (ID: ${p.id})`;
    if (p.nextAppointment) {
      line += `\n  Next appt: ${p.nextAppointment}`;
      if (p.nextApptProvider) line += ` with ${p.nextApptProvider}`;
    } else {
      line += "\n  No upcoming appointment scheduled";
    }
    if (p.referringProvider) {
      line += `\n  Referring: ${p.referringProvider}`;
    }
    return line;
  });

  const header =
    patients.length === 1
      ? `New patient added in AMD: ${patients[0].name}`
      : `${patients.length} new patients added in AMD:`;

  const text = `${header}\n${lines.join("\n")}`;

  const base = teamsWorkerUrl.replace(/\/+$/, "");
  const res = await fetch(`${base}/api/notify`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": teamsApiKey,
    },
    body: JSON.stringify({ chat_id: teamsChatId, text }),
  });
  if (!res.ok) {
    const body = await res.text().catch(() => "(no body)");
    throw new Error(`Teams notify failed (${res.status}): ${body.slice(0, 300)}`);
  }
}

// ---------------------------------------------------------------------------
// Poll logic
// ---------------------------------------------------------------------------

async function pollNewPatients(
  env: AmdEnv,
  teamsWorkerUrl: string,
  teamsApiKey: string,
  teamsChatId: string,
): Promise<number> {
  const state = loadState();
  const today = new Date();
  const todayStr = formatDate(today);
  const lastPollDate =
    state.lastPollDate ?? formatDate(new Date(today.getTime() - 86400_000));
  const seenSet = new Set(state.seenPatientIds ?? []);

  let patients: PatientRecord[];
  if (MOCK_MODE) {
    // Mock: emit a synthetic patient that is guaranteed-new each run.
    const mockId = `MOCK-${Date.now()}`;
    patients = [
      {
        id: mockId,
        name: `Test Patient ${mockId}`,
        createdAt: todayStr,
        changedAt: todayStr,
      },
    ];
    process.stderr.write(`amd-notify: MOCK mode — synthesizing patient ${mockId}\n`);
  } else {
    const xml = await amdApiCall(
      env,
      "getupdatedpatients",
      `datechanged="${escapeXml(lastPollDate)}"`,
      `<patient name="Name" ssn="SSN" changedat="ChangedAt" createdat="CreatedAt"/>`,
    );
    // Response-shape sanity check. If the body does not contain the expected
    // <ppmdmsg> envelope, treat as a parse failure rather than "zero new
    // patients" — otherwise an unexpected response shape would silently
    // advance the cursor.
    if (!/<ppmdmsg\b/i.test(xml)) {
      throw new Error(
        `AMD getupdatedpatients: response missing <ppmdmsg> envelope: ${xml.slice(0, 300)}`,
      );
    }
    patients = parsePatients(xml);
  }

  const newPatients = patients.filter((p) => {
    if (seenSet.has(p.id)) return false;
    if (MOCK_MODE) return true;
    if (p.createdAt && p.createdAt >= lastPollDate) return true;
    return false;
  });

  // Order matters: enrich + Teams delivery happen BEFORE we mutate the
  // cursor. If either throws, the function exits without saveState() and the
  // next timer run retries the same window. This is the only path that
  // advances lastPollDate, so a failed poll cannot silently drop patients.
  if (newPatients.length > 0) {
    if (!MOCK_MODE) await enrichPatients(env, newPatients);
    await sendTeamsNotification(teamsWorkerUrl, teamsApiKey, teamsChatId, newPatients);
    for (const p of newPatients) seenSet.add(p.id);
  }

  const seenArr = Array.from(seenSet);
  if (seenArr.length > 5000) seenArr.splice(0, seenArr.length - 5000);
  saveState({ lastPollDate: todayStr, seenPatientIds: seenArr });

  return newPatients.length;
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

function required(name: string): string {
  const v = process.env[name];
  if (!v) {
    process.stderr.write(`amd-notify: missing required env var ${name}\n`);
    process.exit(1);
  }
  return v;
}

async function main(): Promise<void> {
  const teamsWorkerUrl = required("TEAMS_WORKER_URL");
  const teamsApiKey = required("TEAMS_API_KEY");
  const teamsChatId = required("TEAMS_CHAT_ID");

  const env: AmdEnv = MOCK_MODE
    ? {
        AMD_OFFICE_KEY: process.env.AMD_OFFICE_KEY ?? "MOCK",
        AMD_USERNAME: process.env.AMD_USERNAME ?? "MOCK",
        AMD_PASSWORD: process.env.AMD_PASSWORD ?? "MOCK",
        AMD_APPNAME: process.env.AMD_APPNAME ?? "MOCK",
      }
    : {
        AMD_OFFICE_KEY: required("AMD_OFFICE_KEY"),
        AMD_USERNAME: required("AMD_USERNAME"),
        AMD_PASSWORD: required("AMD_PASSWORD"),
        AMD_APPNAME: required("AMD_APPNAME"),
      };

  const started = Date.now();
  try {
    const count = await pollNewPatients(env, teamsWorkerUrl, teamsApiKey, teamsChatId);
    const ms = Date.now() - started;
    process.stdout.write(
      JSON.stringify({ ok: true, newPatients: count, mock: MOCK_MODE, ms }) + "\n",
    );
  } catch (err) {
    const ms = Date.now() - started;
    process.stderr.write(
      JSON.stringify({ ok: false, error: String(err), ms }) + "\n",
    );
    process.exit(1);
  }
}

await main();
