/**
 * RingCentral → Microsoft Teams call notification integration.
 *
 * Receives RC call-log webhook events, enriches with recording/transcript,
 * and posts Adaptive Cards to the Admin team notifications channel.
 */

import {
  readFileSync,
  writeFileSync,
  existsSync,
} from "fs";
import { join } from "path";
import { homedir } from "os";
import { createHmac } from "crypto";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

type CallNotificationType =
  | "inbound_completed"
  | "inbound_missed"
  | "inbound_voicemail"
  | "outbound_completed"
  | "outbound_missed";

interface RcSubscriptionState {
  id: string;
  expirationTime: string;
  createdAt: string;
}

interface ExtensionInfo {
  id: string;
  name: string;
  extensionNumber: string;
}

interface ServerDeps {
  botPost: (serviceUrl: string, path: string, body: Record<string, unknown>) => Promise<Record<string, unknown>>;
  getGraphToken: () => Promise<string>;
  audit: (direction: "IN" | "OUT", who: string, convId: string, preview: string) => void;
  appId: string;
}

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

const RC_CLIENT_ID = process.env.RINGCENTRAL_CLIENT_ID ?? "";
const RC_CLIENT_SECRET = process.env.RINGCENTRAL_CLIENT_SECRET ?? "";
const RC_JWT = process.env.RINGCENTRAL_JWT ?? "";
const RC_SERVER = process.env.RINGCENTRAL_SERVER ?? "https://platform.ringcentral.com";
const RC_MEDIA_SERVER = "https://media.ringcentral.com";
const RC_WEBHOOK_SECRET = process.env.RC_WEBHOOK_SECRET ?? "";

const AMD_OFFICE_KEY = process.env.AMD_OFFICE_KEY ?? "";
const AMD_USERNAME = process.env.AMD_USERNAME ?? "";
const AMD_PASSWORD = process.env.AMD_PASSWORD ?? "";
const AMD_APPNAME = process.env.AMD_APPNAME ?? "TEMP";

const MAX_RECORDING_BYTES = 25 * 1024 * 1024; // 25 MB

const NOTIFICATIONS_CHANNEL = {
  teamId: process.env.RC_TEAMS_TEAM_ID ?? "552d4486-c93f-42ef-9f67-fc192cffda33",
  channelId: process.env.RC_TEAMS_CHANNEL_ID ?? "19:f8ffcd756a474e8bad48e72fcb214073@thread.tacv2",
  tenantId: process.env.RC_TEAMS_TENANT_ID ?? "707a7153-af93-4b65-ae01-bfa6febbffdb",
  serviceUrl: `https://smba.trafficmanager.net/amer/${process.env.RC_TEAMS_TENANT_ID ?? "707a7153-af93-4b65-ae01-bfa6febbffdb"}/`,
};

const STATE_DIR = join(homedir(), ".claude", "channels", "teams");
const RC_SUBSCRIPTION_FILE = join(STATE_DIR, "rc-subscription.json");
const PROCESSED_CALLS_FILE = join(STATE_DIR, "rc-processed-calls.json");

const RC_WEBHOOK_URL = "https://claude-cloud.tail053faf.ts.net/rc";
const DAILY_SUMMARY_HOUR = 18; // 6 PM local

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

let rcAccessToken: string | null = null;
let rcTokenExpiresAt = 0;

async function rcAuthenticate(): Promise<string> {
  if (rcAccessToken && Date.now() < rcTokenExpiresAt - 60_000) {
    return rcAccessToken;
  }
  const basic = Buffer.from(`${RC_CLIENT_ID}:${RC_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: RC_JWT,
    }).toString(),
  });
  if (!res.ok) {
    throw new Error(`RC auth failed (${res.status}): ${await res.text()}`);
  }
  const data = (await res.json()) as { access_token: string; expires_in: number };
  rcAccessToken = data.access_token;
  rcTokenExpiresAt = Date.now() + data.expires_in * 1000;
  process.stderr.write("rc-notifications: authenticated with RingCentral\n");
  return rcAccessToken;
}

async function rcGet(path: string, params?: Record<string, string>): Promise<unknown> {
  const token = await rcAuthenticate();
  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(`RC GET ${path} (${res.status}): ${await res.text()}`);
  }
  return res.json();
}

async function rcPost(path: string, body: Record<string, unknown>): Promise<unknown> {
  const token = await rcAuthenticate();
  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(`RC POST ${path} (${res.status}): ${await res.text()}`);
  }
  return res.json();
}

async function rcDownloadBinary(url: string): Promise<{ buffer: Buffer; contentType: string }> {
  const token = await rcAuthenticate();
  const mediaUrl = url.replace(RC_SERVER, RC_MEDIA_SERVER);
  const res = await fetch(mediaUrl, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) {
    throw new Error(`RC download failed (${res.status}): ${await res.text()}`);
  }
  const contentLength = parseInt(res.headers.get("content-length") ?? "0", 10);
  if (contentLength > MAX_RECORDING_BYTES) {
    throw new Error(`Recording too large (${contentLength} bytes, max ${MAX_RECORDING_BYTES})`);
  }
  const buffer = Buffer.from(await res.arrayBuffer());
  if (buffer.length > MAX_RECORDING_BYTES) {
    throw new Error(`Recording too large (${buffer.length} bytes, max ${MAX_RECORDING_BYTES})`);
  }
  const contentType = res.headers.get("content-type") ?? "audio/mpeg";
  return { buffer, contentType };
}

// ---------------------------------------------------------------------------
// Extension name cache (US-009)
// ---------------------------------------------------------------------------

let extensionCache: Map<string, ExtensionInfo> = new Map();
let extensionCacheUpdatedAt = 0;
const EXTENSION_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours

async function refreshExtensionCache(): Promise<void> {
  try {
    const data = (await rcGet("/restapi/v1.0/account/~/extension", {
      perPage: "500",
      status: "Enabled",
    })) as { records: Array<Record<string, unknown>> };
    extensionCache = new Map();
    for (const ext of data.records ?? []) {
      const id = String(ext.id ?? "");
      const contact = ext.contact as Record<string, unknown> | undefined;
      const name = contact
        ? `${String(contact.firstName ?? "")} ${String(contact.lastName ?? "")}`.trim()
        : String(ext.name ?? "");
      extensionCache.set(id, {
        id,
        name: name || `Extension ${String(ext.extensionNumber ?? id)}`,
        extensionNumber: String(ext.extensionNumber ?? ""),
      });
    }
    extensionCacheUpdatedAt = Date.now();
    process.stderr.write(`rc-notifications: cached ${extensionCache.size} extensions\n`);
  } catch (err) {
    process.stderr.write(`rc-notifications: extension cache refresh failed: ${String(err)}\n`);
  }
}

function resolveExtensionName(extId: string): string {
  const info = extensionCache.get(extId);
  if (!info) return extId;
  return info.extensionNumber
    ? `${info.name} (ext ${info.extensionNumber})`
    : info.name;
}

// ---------------------------------------------------------------------------
// Deduplication (US-008)
// ---------------------------------------------------------------------------

const processedCallIds = new Set<string>();
const MAX_PROCESSED_IDS = 500;

function loadProcessedCalls(): void {
  try {
    if (existsSync(PROCESSED_CALLS_FILE)) {
      const data = JSON.parse(readFileSync(PROCESSED_CALLS_FILE, "utf-8")) as string[];
      data.forEach((id) => processedCallIds.add(id));
    }
  } catch { /* ignore corrupt file */ }
}

function saveProcessedCalls(): void {
  const arr = [...processedCallIds].slice(-MAX_PROCESSED_IDS);
  writeFileSync(PROCESSED_CALLS_FILE, JSON.stringify(arr), { mode: 0o600 });
}

function isAlreadyProcessed(callId: string): boolean {
  return processedCallIds.has(callId);
}

function markProcessed(callId: string): void {
  processedCallIds.add(callId);
  if (processedCallIds.size > MAX_PROCESSED_IDS) {
    const arr = [...processedCallIds];
    for (let i = 0; i < 200; i++) processedCallIds.delete(arr[i]);
  }
  saveProcessedCalls();
}

// ---------------------------------------------------------------------------
// Call classification (US-003)
// ---------------------------------------------------------------------------

function classifyCall(record: Record<string, unknown>): CallNotificationType {
  const direction = String(record.direction ?? "");
  const result = String(record.result ?? "");

  if (direction === "Inbound") {
    if (result === "Voicemail" || result === "Sent to Voicemail") return "inbound_voicemail";
    if (["Missed", "No Answer", "Hang Up", "Abandoned"].includes(result)) return "inbound_missed";
    return "inbound_completed";
  }
  if (["No Answer", "Busy", "Declined", "Rejected"].includes(result)) return "outbound_missed";
  return "outbound_completed";
}

// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------

function formatDuration(seconds: number): string {
  if (seconds < 60) return `${seconds}s`;
  const mins = Math.floor(seconds / 60);
  const secs = seconds % 60;
  if (mins < 60) return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
  const hrs = Math.floor(mins / 60);
  const remMins = mins % 60;
  return `${hrs}h ${remMins}m`;
}

function formatDateTime(iso: string | undefined): string {
  if (!iso) return "Unknown time";
  const d = new Date(iso);
  return d.toLocaleString("en-US", {
    weekday: "short",
    month: "short",
    day: "numeric",
    hour: "numeric",
    minute: "2-digit",
    hour12: true,
    timeZone: "America/Chicago",
  });
}

function formatParty(party: Record<string, unknown> | undefined): string {
  if (!party) return "Unknown";
  const name = String(party.name ?? "");
  const number = String(party.phoneNumber ?? "");
  const extId = String((party.extensionId ?? (party as Record<string, unknown>).extensionNumber) ?? "");

  const resolvedName = extId ? resolveExtensionName(extId) : name;

  if (resolvedName && number) return `${resolvedName} (${number})`;
  if (resolvedName) return resolvedName;
  if (number) return number;
  return "Unknown";
}

// ---------------------------------------------------------------------------
// Recording download with retry (US-005)
// ---------------------------------------------------------------------------

async function downloadRecordingWithRetry(
  contentUri: string,
  maxRetries = 3,
  initialDelayMs = 3000,
): Promise<{ buffer: Buffer; contentType: string } | null> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await rcDownloadBinary(contentUri);
    } catch (err) {
      if (attempt === maxRetries) {
        process.stderr.write(
          `rc-notifications: recording download failed after ${maxRetries + 1} attempts: ${String(err)}\n`,
        );
        return null;
      }
      const delay = initialDelayMs * Math.pow(2, attempt);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
  return null;
}

async function uploadRecordingToTeams(
  deps: ServerDeps,
  buffer: Buffer,
  filename: string,
  contentType: string,
): Promise<string | null> {
  try {
    const graphToken = await deps.getGraphToken();
    if (!graphToken) return null;

    const { teamId, channelId } = NOTIFICATIONS_CHANNEL;

    // Get the channel's SharePoint files folder
    const folderRes = await fetch(
      `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${encodeURIComponent(channelId)}/filesFolder`,
      { headers: { Authorization: `Bearer ${graphToken}` } },
    );

    if (!folderRes.ok) {
      process.stderr.write(
        `rc-notifications: filesFolder lookup failed (${folderRes.status}): ${await folderRes.text()}\n`,
      );
      return null;
    }

    const folder = (await folderRes.json()) as {
      parentReference: { driveId: string };
      id: string;
    };
    const driveId = folder.parentReference.driveId;
    const folderId = folder.id;

    // Upload file
    const uploadRes = await fetch(
      `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${folderId}:/${filename}:/content`,
      {
        method: "PUT",
        headers: {
          Authorization: `Bearer ${graphToken}`,
          "Content-Type": contentType,
        },
        body: new Uint8Array(buffer),
      },
    );

    if (!uploadRes.ok) {
      process.stderr.write(
        `rc-notifications: file upload failed (${uploadRes.status}): ${await uploadRes.text()}\n`,
      );
      return null;
    }

    const uploadData = (await uploadRes.json()) as { webUrl: string };
    return uploadData.webUrl;
  } catch (err) {
    process.stderr.write(`rc-notifications: recording upload failed: ${String(err)}\n`);
    return null;
  }
}

// ---------------------------------------------------------------------------
// Voicemail transcript (US-006)
// ---------------------------------------------------------------------------

async function fetchVoicemailTranscript(record: Record<string, unknown>): Promise<string | null> {
  const message = record.message as Record<string, unknown> | undefined;
  if (!message?.id) return null;

  try {
    const msgDetail = (await rcGet(
      `/restapi/v1.0/account/~/extension/~/message-store/${String(message.id)}`,
    )) as Record<string, unknown>;

    const attachments = msgDetail.attachments as Array<Record<string, unknown>> | undefined;
    if (!attachments) return null;

    const transcriptAtt = attachments.find(
      (a) =>
        String(a.type ?? "").includes("Text") ||
        String(a.contentType ?? "").includes("text/plain"),
    );

    if (transcriptAtt?.uri) {
      const token = await rcAuthenticate();
      const res = await fetch(String(transcriptAtt.uri), {
        headers: { Authorization: `Bearer ${token}` },
      });
      if (res.ok) return await res.text();
    }
  } catch (err) {
    process.stderr.write(`rc-notifications: transcript fetch failed: ${String(err)}\n`);
  }
  return null;
}

// ---------------------------------------------------------------------------
// AdvancedMD caller enrichment (US-010)
// ---------------------------------------------------------------------------

let amdToken: string | null = null;
let amdWebServer: string | null = null;
let amdTokenExpiresAt = 0;
const AMD_TOKEN_TTL = 30 * 60 * 1000; // 30 minutes

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

async function amdLogin(): Promise<{ token: string; webServer: string } | null> {
  if (amdToken && amdWebServer && Date.now() < amdTokenExpiresAt) {
    return { token: amdToken, webServer: amdWebServer };
  }
  amdToken = null;
  amdWebServer = null;
  if (!AMD_OFFICE_KEY || !AMD_USERNAME || !AMD_PASSWORD) return null;

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

  const loginXml = `<?xml version="1.0"?>
<ppmdmsg>
  <action>login</action>
  <class>login</class>
  <appname>${escXml(AMD_APPNAME)}</appname>
  <parameter>
    <officecode>${escXml(AMD_OFFICE_KEY)}</officecode>
    <user>${escXml(AMD_USERNAME)}</user>
    <password>${escXml(AMD_PASSWORD)}</password>
  </parameter>
</ppmdmsg>`;

  try {
    const res = await fetch(PARTNER_LOGIN_URL, {
      method: "POST",
      headers: { "Content-Type": "text/xml" },
      body: loginXml,
    });
    const xml = await res.text();
    const tokenMatch = xml.match(/ppmdmsg_cookie="([^"]*)"/);
    const serverMatch = xml.match(/webserver="([^"]*)"/);
    if (tokenMatch && serverMatch) {
      amdToken = tokenMatch[1];
      amdWebServer = serverMatch[1];
      amdTokenExpiresAt = Date.now() + AMD_TOKEN_TTL;
      return { token: amdToken, webServer: amdWebServer };
    }
  } catch (err) {
    process.stderr.write(`rc-notifications: AMD login failed: ${String(err)}\n`);
  }
  return null;
}

async function lookupPatientByPhone(phoneNumber: string): Promise<string | null> {
  const session = await amdLogin();
  if (!session) return null;

  // Strip non-digits, take last 10 digits
  const digits = phoneNumber.replace(/\D/g, "").slice(-10);
  if (digits.length < 7) return null;

  const searchXml = `<?xml version="1.0"?>
<ppmdmsg>
  <action>getlist</action>
  <class>api</class>
  <appname>${escXml(AMD_APPNAME)}</appname>
  <parameter>
    <ppmdmsg_cookie>${escXml(session.token)}</ppmdmsg_cookie>
    <lookup_type>patient</lookup_type>
    <search>${escXml(digits)}</search>
  </parameter>
</ppmdmsg>`;

  try {
    const res = await fetch(
      `${session.webServer}/xmlrpc/processrequest.aspx`,
      {
        method: "POST",
        headers: { "Content-Type": "text/xml" },
        body: searchXml,
      },
    );
    const xml = await res.text();
    // Parse first patient result
    const nameMatch = xml.match(/patientname="([^"]*)"/);
    if (nameMatch) return nameMatch[1];
  } catch (err) {
    process.stderr.write(`rc-notifications: AMD patient lookup failed: ${String(err)}\n`);
  }
  return null;
}

// ---------------------------------------------------------------------------
// Adaptive Card builder (US-004)
// ---------------------------------------------------------------------------

const CALL_TYPE_CONFIG: Record<
  CallNotificationType,
  { icon: string; title: string; color: string }
> = {
  inbound_completed:  { icon: "\u{1F4DE}", title: "Inbound Call Completed",  color: "Good" },
  inbound_missed:     { icon: "\u{1F534}", title: "Missed Call",             color: "Attention" },
  inbound_voicemail:  { icon: "\u{1F4E8}", title: "New Voicemail",           color: "Warning" },
  outbound_completed: { icon: "\u{1F4F1}", title: "Outbound Call Completed", color: "Good" },
  outbound_missed:    { icon: "\u{1F7E0}", title: "Outbound Call Failed",    color: "Warning" },
};

function buildCallNotificationCard(
  record: Record<string, unknown>,
  callType: CallNotificationType,
  recordingUrl: string | null,
  transcript: string | null,
  patientName: string | null,
): Record<string, unknown> {
  const from = record.from as Record<string, unknown> | undefined;
  const to = record.to as Record<string, unknown> | undefined;
  const duration = record.duration as number | undefined;
  const startTime = record.startTime as string | undefined;

  const config = CALL_TYPE_CONFIG[callType];

  const facts: Array<{ title: string; value: string }> = [
    { title: "From", value: formatParty(from) },
    { title: "To", value: formatParty(to) },
  ];

  if (patientName) {
    facts.push({ title: "Patient", value: patientName });
  }

  if (duration !== undefined && duration > 0) {
    facts.push({ title: "Duration", value: formatDuration(duration) });
  }

  facts.push({ title: "Result", value: String(record.result ?? "Unknown") });

  const body: Array<Record<string, unknown>> = [
    {
      type: "ColumnSet",
      columns: [
        {
          type: "Column",
          width: "auto",
          items: [{ type: "TextBlock", text: config.icon, size: "Large" }],
        },
        {
          type: "Column",
          width: "stretch",
          items: [
            {
              type: "TextBlock",
              text: config.title,
              weight: "Bolder",
              size: "Medium",
              color: config.color,
            },
            {
              type: "TextBlock",
              text: formatDateTime(startTime),
              spacing: "None",
              isSubtle: true,
              size: "Small",
            },
          ],
        },
      ],
    },
    { type: "FactSet", facts },
  ];

  if (transcript) {
    body.push({
      type: "ActionSet",
      actions: [
        {
          type: "Action.ShowCard",
          title: "Show Transcript",
          card: {
            type: "AdaptiveCard",
            body: [
              {
                type: "TextBlock",
                text: transcript.slice(0, 3000),
                wrap: true,
                size: "Small",
                fontType: "Monospace",
              },
            ],
          },
        },
      ],
    });
  }

  if (recordingUrl) {
    body.push({
      type: "ActionSet",
      actions: [
        {
          type: "Action.OpenUrl",
          title: "Play Recording",
          url: recordingUrl,
        },
      ],
    });
  }

  return {
    type: "AdaptiveCard",
    version: "1.5",
    body,
    msteams: { width: "Full" },
  };
}

// ---------------------------------------------------------------------------
// Channel posting (US-004/007)
// ---------------------------------------------------------------------------

let serverDeps: ServerDeps | null = null;

async function sendToNotificationsChannel(
  card: Record<string, unknown>,
  summaryText: string,
): Promise<void> {
  if (!serverDeps) throw new Error("rc-notifications not initialized");

  const activity: Record<string, unknown> = {
    type: "message",
    from: {
      id: `28:${serverDeps.appId}`,
      name: "Exult Agent",
    },
    conversation: {
      id: NOTIFICATIONS_CHANNEL.channelId,
      conversationType: "channel",
    },
    channelData: {
      teamsChannelId: NOTIFICATIONS_CHANNEL.channelId,
      teamsTeamId: NOTIFICATIONS_CHANNEL.teamId,
    },
    attachments: [
      {
        contentType: "application/vnd.microsoft.card.adaptive",
        content: card,
      },
    ],
  };

  await serverDeps.botPost(
    NOTIFICATIONS_CHANNEL.serviceUrl,
    `/v3/conversations/${encodeURIComponent(NOTIFICATIONS_CHANNEL.channelId)}/activities`,
    activity,
  );

  serverDeps.audit("OUT", "Exult Agent", NOTIFICATIONS_CHANNEL.channelId, summaryText);
}

// ---------------------------------------------------------------------------
// Core webhook processing
// ---------------------------------------------------------------------------

async function processCallRecords(records: Array<Record<string, unknown>>): Promise<void> {
  for (const record of records) {
    const callId = String(record.id ?? "");
    if (!callId || isAlreadyProcessed(callId)) continue;
    markProcessed(callId);

    try {
      const callType = classifyCall(record);

      // Parallel enrichment: recording, transcript, patient lookup
      const recording = record.recording as Record<string, unknown> | undefined;
      const fromParty = record.from as Record<string, unknown> | undefined;
      const inboundPhone = String(fromParty?.phoneNumber ?? "");

      const [recordingResult, transcript, patientName] = await Promise.all([
        recording?.contentUri
          ? downloadRecordingWithRetry(String(recording.contentUri))
          : Promise.resolve(null),
        callType === "inbound_voicemail"
          ? fetchVoicemailTranscript(record)
          : Promise.resolve(null),
        inboundPhone && callType.startsWith("inbound")
          ? lookupPatientByPhone(inboundPhone).catch(() => null)
          : Promise.resolve(null),
      ]);

      // Upload recording to Teams if we got one
      let recordingUrl: string | null = null;
      if (recordingResult) {
        const ext = recordingResult.contentType.includes("wav") ? "wav" : "mp3";
        const ts = new Date().toISOString().replace(/[:.]/g, "-");
        const filename = `call-recording-${ts}.${ext}`;
        recordingUrl = await uploadRecordingToTeams(
          serverDeps!,
          recordingResult.buffer,
          filename,
          recordingResult.contentType,
        );
      }

      const card = buildCallNotificationCard(record, callType, recordingUrl, transcript, patientName);
      const direction = String(record.direction ?? "");
      const result = String(record.result ?? "");
      const summary = `[${callType}] ${direction} ${result} — ${formatParty(fromParty)} → ${formatParty(record.to as Record<string, unknown> | undefined)}`;

      await sendToNotificationsChannel(card, summary);
      process.stderr.write(`rc-notifications: posted ${callType} notification for ${callId}\n`);
    } catch (err) {
      process.stderr.write(`rc-notifications: failed to process call ${callId}: ${String(err)}\n`);
    }
  }
}

async function processRcWebhook(payload: Record<string, unknown>): Promise<void> {
  const event = String(payload.event ?? "");
  const body = payload.body as Record<string, unknown> | undefined;

  // Telephony session events — fire when calls change state
  if (event.includes("telephony/sessions")) {
    const parties = (body?.parties ?? []) as Array<Record<string, unknown>>;
    if (parties.length === 0) return;

    const terminalCodes = new Set(["Disconnected", "Gone", "Voicemail"]);
    const allTerminal = parties.every((p) => {
      const status = p.status as Record<string, unknown> | undefined;
      return terminalCodes.has(String(status?.code ?? ""));
    });

    if (!allTerminal) {
      // Some parties still active (transfer in progress, etc.) — skip
      return;
    }

    // Brief delay for call log record to appear
    await new Promise((r) => setTimeout(r, 3000));

    const sessionId = String(body?.telephonySessionId ?? body?.sessionId ?? "");
    if (sessionId && isAlreadyProcessed(`session:${sessionId}`)) return;
    if (sessionId) markProcessed(`session:${sessionId}`);
  }

  // Message-store events — voicemail deposits
  if (event.includes("message-store")) {
    const changes = (body?.changes ?? []) as Array<Record<string, unknown>>;
    const hasVoicemail = changes.some(
      (c) => String(c.type ?? "") === "VoiceMail" && Number(c.newCount ?? 0) > 0,
    );
    if (!hasVoicemail && !event.includes("telephony")) return;
  }

  // Fetch recent call log records to find the completed call
  const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
  const data = (await rcGet("/restapi/v1.0/account/~/call-log", {
    dateFrom: fiveMinAgo,
    perPage: "10",
    view: "Detailed",
  })) as { records: Array<Record<string, unknown>> };

  const records = data.records ?? [];
  if (records.length === 0) return;

  process.stderr.write(`rc-notifications: processing ${records.length} recent call records\n`);
  await processCallRecords(records);
}

// ---------------------------------------------------------------------------
// Subscription lifecycle (US-007)
// ---------------------------------------------------------------------------

let renewalTimer: ReturnType<typeof setTimeout> | null = null;

function loadRcSubscription(): RcSubscriptionState | null {
  try {
    if (existsSync(RC_SUBSCRIPTION_FILE)) {
      return JSON.parse(readFileSync(RC_SUBSCRIPTION_FILE, "utf-8")) as RcSubscriptionState;
    }
  } catch { /* ignore */ }
  return null;
}

function saveRcSubscription(state: RcSubscriptionState): void {
  writeFileSync(RC_SUBSCRIPTION_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
}

function scheduleRcRenewal(subscriptionId: string): void {
  if (renewalTimer) clearTimeout(renewalTimer);

  const saved = loadRcSubscription();
  let renewInMs = 6 * 24 * 60 * 60 * 1000; // 6 days default

  if (saved?.expirationTime) {
    const expiresAt = new Date(saved.expirationTime).getTime();
    const renewAt = expiresAt - 60 * 60 * 1000; // 1 hour before expiry
    renewInMs = Math.max(renewAt - Date.now(), 60_000);
  }

  renewalTimer = setTimeout(async () => {
    try {
      await rcPost(`/restapi/v1.0/subscription/${subscriptionId}/renew`, {});
      const sub = (await rcGet(
        `/restapi/v1.0/subscription/${subscriptionId}`,
      )) as Record<string, unknown>;
      saveRcSubscription({
        id: subscriptionId,
        expirationTime: String(sub.expirationTime),
        createdAt: String(sub.creationTime),
      });
      process.stderr.write("rc-notifications: subscription renewed\n");
      scheduleRcRenewal(subscriptionId);
    } catch (err) {
      process.stderr.write(
        `rc-notifications: renewal failed, recreating: ${String(err)}\n`,
      );
      ensureRcSubscription().catch(() => {});
    }
  }, renewInMs);

  const hours = Math.round(renewInMs / 3600000);
  process.stderr.write(`rc-notifications: renewal scheduled in ${hours}h\n`);
}

async function ensureRcSubscription(): Promise<void> {
  const saved = loadRcSubscription();

  if (saved) {
    try {
      const sub = (await rcGet(
        `/restapi/v1.0/subscription/${saved.id}`,
      )) as Record<string, unknown>;
      if (sub.status === "Active") {
        await rcPost(`/restapi/v1.0/subscription/${saved.id}/renew`, {});
        process.stderr.write(`rc-notifications: subscription ${saved.id} renewed\n`);
        scheduleRcRenewal(saved.id);
        return;
      }
    } catch {
      process.stderr.write("rc-notifications: saved subscription gone, creating new\n");
    }
  }

  // telephony/sessions fires on call state changes (ring, connect, disconnect)
  // message-store covers voicemail deposits
  const result = (await rcPost("/restapi/v1.0/subscription", {
    eventFilters: [
      "/restapi/v1.0/account/~/telephony/sessions",
      "/restapi/v1.0/account/~/extension/~/message-store",
    ],
    deliveryMode: {
      transportType: "WebHook",
      address: RC_WEBHOOK_URL,
    },
    expiresIn: 604800,
  })) as Record<string, unknown>;

  const subId = String(result.id);
  const expirationTime = String(result.expirationTime);

  saveRcSubscription({
    id: subId,
    expirationTime,
    createdAt: new Date().toISOString(),
  });
  process.stderr.write(
    `rc-notifications: subscription created: ${subId}, expires: ${expirationTime}\n`,
  );
  scheduleRcRenewal(subId);
}

// ---------------------------------------------------------------------------
// Daily call summary (US-011)
// ---------------------------------------------------------------------------

async function postDailySummary(): Promise<void> {
  const todayStart = new Date();
  todayStart.setHours(0, 0, 0, 0);
  const todayEnd = new Date();

  const data = (await rcGet("/restapi/v1.0/account/~/call-log", {
    dateFrom: todayStart.toISOString(),
    dateTo: todayEnd.toISOString(),
    perPage: "250",
    view: "Simple",
  })) as { records: Array<Record<string, unknown>> };

  const records = data.records ?? [];
  if (records.length === 0) {
    process.stderr.write("rc-notifications: no calls today, skipping daily summary\n");
    return;
  }

  let inbound = 0;
  let outbound = 0;
  let missed = 0;
  let voicemails = 0;
  let totalDuration = 0;

  for (const r of records) {
    const direction = String(r.direction ?? "");
    const result = String(r.result ?? "");
    const duration = (r.duration as number) ?? 0;

    if (direction === "Inbound") {
      inbound++;
      if (["Missed", "No Answer", "Hang Up", "Abandoned"].includes(result)) missed++;
      if (result === "Voicemail" || result === "Sent to Voicemail") voicemails++;
    } else {
      outbound++;
    }
    totalDuration += duration;
  }

  const avgDuration = records.length > 0 ? Math.round(totalDuration / records.length) : 0;

  const card: Record<string, unknown> = {
    type: "AdaptiveCard",
    version: "1.5",
    body: [
      {
        type: "TextBlock",
        text: "\u{1F4CA} Daily Call Summary",
        weight: "Bolder",
        size: "Medium",
      },
      {
        type: "TextBlock",
        text: formatDateTime(todayStart.toISOString()),
        isSubtle: true,
        size: "Small",
        spacing: "None",
      },
      {
        type: "FactSet",
        facts: [
          { title: "Total Calls", value: String(records.length) },
          { title: "Inbound", value: String(inbound) },
          { title: "Outbound", value: String(outbound) },
          { title: "Missed", value: missed > 0 ? `\u{26A0}\u{FE0F} ${missed}` : "0" },
          { title: "Voicemails", value: String(voicemails) },
          { title: "Avg Duration", value: formatDuration(avgDuration) },
          { title: "Total Duration", value: formatDuration(totalDuration) },
        ],
      },
    ],
    msteams: { width: "Full" },
  };

  await sendToNotificationsChannel(card, `[daily summary] ${records.length} calls, ${missed} missed`);
  process.stderr.write(`rc-notifications: daily summary posted (${records.length} calls)\n`);
}

function scheduleDailySummary(): void {
  const now = new Date();
  const target = new Date();
  target.setHours(DAILY_SUMMARY_HOUR, 0, 0, 0);
  if (target.getTime() <= now.getTime()) {
    target.setDate(target.getDate() + 1);
  }
  const delayMs = target.getTime() - now.getTime();

  setTimeout(async () => {
    try {
      await postDailySummary();
    } catch (err) {
      process.stderr.write(`rc-notifications: daily summary failed: ${String(err)}\n`);
    }
    // Reschedule for tomorrow
    scheduleDailySummary();
  }, delayMs);

  const hours = Math.round(delayMs / 3600000);
  process.stderr.write(`rc-notifications: daily summary scheduled in ${hours}h\n`);
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

export function isRcConfigured(): boolean {
  return Boolean(RC_CLIENT_ID && RC_CLIENT_SECRET && RC_JWT);
}

export function verifyRcWebhook(
  body: string,
  signatureHeader: string | null,
): boolean {
  if (!RC_WEBHOOK_SECRET) return true; // no secret configured = skip validation
  if (!signatureHeader) return false;
  const expected = createHmac("sha256", RC_WEBHOOK_SECRET)
    .update(body)
    .digest("hex");
  return signatureHeader === expected;
}

// ---------------------------------------------------------------------------
// Call log polling (primary mechanism — reliable unlike webhooks)
// ---------------------------------------------------------------------------

// Polling is a safety net — webhooks are the primary delivery mechanism.
// Poll interval is longer since webhooks handle the real-time path.
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
let pollWatermarkMs = Date.now() - 10 * 60 * 1000; // start 10 min ago

async function pollCallLog(): Promise<void> {
  try {
    const data = (await rcGet("/restapi/v1.0/account/~/call-log", {
      dateFrom: new Date(pollWatermarkMs).toISOString(),
      perPage: "25",
      view: "Detailed",
    })) as { records: Array<Record<string, unknown>> };

    const records = data.records ?? [];
    if (records.length === 0) return;

    // Update watermark to latest record time using Date parsing
    for (const r of records) {
      const t = String(r.startTime ?? "");
      if (t) {
        const ms = new Date(t).getTime();
        if (ms > pollWatermarkMs) pollWatermarkMs = ms;
      }
    }

    const unprocessed = records.filter((r) => !isAlreadyProcessed(String(r.id ?? "")));
    if (unprocessed.length > 0) {
      process.stderr.write(`rc-notifications: poll found ${unprocessed.length} new call(s)\n`);
      await processCallRecords(unprocessed);
    }
  } catch (err) {
    process.stderr.write(`rc-notifications: poll error: ${String(err)}\n`);
  }
}

function startCallLogPolling(): void {
  // Initial poll after a short delay
  setTimeout(() => {
    pollCallLog().catch(() => {});
  }, 10_000);

  setInterval(() => {
    pollCallLog().catch(() => {});
  }, POLL_INTERVAL_MS);

  process.stderr.write(
    `rc-notifications: call log polling started (${POLL_INTERVAL_MS / 1000}s interval, safety net)\n`,
  );
}

// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------

export async function initRcNotifications(deps: ServerDeps): Promise<void> {
  serverDeps = deps;

  if (!isRcConfigured()) {
    process.stderr.write(
      "rc-notifications: RC credentials not configured, skipping initialization\n",
    );
    return;
  }

  loadProcessedCalls();

  try {
    await rcAuthenticate();
  } catch (err) {
    process.stderr.write(`rc-notifications: initial auth failed: ${String(err)}\n`);
    return;
  }

  // Load extension cache and create webhook subscription in parallel
  await Promise.all([
    refreshExtensionCache(),
    ensureRcSubscription().catch((err) => {
      process.stderr.write(`rc-notifications: subscription setup failed: ${String(err)}\n`);
    }),
  ]);

  // Start polling as primary mechanism (webhooks supplement it)
  startCallLogPolling();

  // Schedule periodic extension cache refresh
  setInterval(() => {
    refreshExtensionCache().catch(() => {});
  }, EXTENSION_CACHE_TTL);

  // Schedule daily summary
  scheduleDailySummary();

  process.stderr.write("rc-notifications: initialized\n");
}

export async function handleRcWebhook(payload: Record<string, unknown>): Promise<void> {
  return processRcWebhook(payload);
}

export { postDailySummary };
