/**
 * Exult Email Worker
 *
 * Always-on Cloudflare Worker that polls Microsoft Graph for new emails
 * via Cron Triggers (every minute), queues them to KV, and provides
 * API endpoints for Claude to poll and send replies.
 *
 * Monitors: gautam@exulthealthcare.com (configurable via MS365_MAILBOX)
 */

interface Env {
  EMAILS: KVNamespace;
  MS365_CLIENT_ID: string;
  MS365_CLIENT_SECRET: string;
  MS365_TENANT_ID: string;
  MS365_MAILBOX: string;
  API_KEY: string;
}

interface QueuedEmail {
  ts: string;
  messageId: string;
  conversationId: string;
  from: string;
  fromName: string;
  to: string[];
  cc: string[];
  subject: string;
  bodyPreview: string;
  body: string;
  hasAttachments: boolean;
  receivedAt: string;
  importance: string;
}

const GRAPH_BASE = "https://graph.microsoft.com/v1.0";

// -------------------------------------------------------------------------
// Graph token management
// -------------------------------------------------------------------------

let cachedToken: { token: string; expiresAt: number } | null = null;

async function getGraphToken(env: Env): Promise<string> {
  if (cachedToken && Date.now() < cachedToken.expiresAt - 60_000) {
    return cachedToken.token;
  }

  const res = await fetch(
    `https://login.microsoftonline.com/${env.MS365_TENANT_ID}/oauth2/v2.0/token`,
    {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        client_id: env.MS365_CLIENT_ID,
        client_secret: env.MS365_CLIENT_SECRET,
        scope: "https://graph.microsoft.com/.default",
        grant_type: "client_credentials",
      }).toString(),
    },
  );

  if (!res.ok) {
    throw new Error(`Token failed (${res.status}): ${await res.text()}`);
  }

  const data = (await res.json()) as { access_token: string; expires_in: number };
  cachedToken = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 };
  return cachedToken.token;
}

async function graphGet<T>(env: Env, path: string): Promise<T> {
  const token = await getGraphToken(env);
  const url = path.startsWith("http") ? path : `${GRAPH_BASE}${path}`;
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) {
    throw new Error(`Graph GET ${path} (${res.status}): ${await res.text()}`);
  }
  return (await res.json()) as T;
}

async function graphPost(env: Env, path: string, body: unknown): Promise<void> {
  const token = await getGraphToken(env);
  const res = await fetch(`${GRAPH_BASE}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok && res.status !== 202) {
    throw new Error(`Graph POST ${path} (${res.status}): ${await res.text()}`);
  }
}

async function graphPatch(env: Env, path: string, body: unknown): Promise<void> {
  const token = await getGraphToken(env);
  const res = await fetch(`${GRAPH_BASE}${path}`, {
    method: "PATCH",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok && res.status !== 204) {
    throw new Error(`Graph PATCH ${path} (${res.status}): ${await res.text()}`);
  }
}

// -------------------------------------------------------------------------
// HTML to plain text
// -------------------------------------------------------------------------

function htmlToText(html: string): string {
  return html
    .replace(/<br\s*\/?>/gi, "\n")
    .replace(/<\/p>/gi, "\n\n")
    .replace(/<\/div>/gi, "\n")
    .replace(/<\/li>/gi, "\n")
    .replace(/<[^>]+>/g, "")
    .replace(/&nbsp;/g, " ")
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

// -------------------------------------------------------------------------
// Delta polling (Cron Trigger)
// -------------------------------------------------------------------------

interface GraphMessage {
  id: string;
  conversationId?: string;
  subject?: string;
  bodyPreview?: string;
  body?: { content: string; contentType: string };
  from?: { emailAddress: { address: string; name: string } };
  toRecipients?: Array<{ emailAddress: { address: string; name: string } }>;
  ccRecipients?: Array<{ emailAddress: { address: string; name: string } }>;
  receivedDateTime?: string;
  hasAttachments?: boolean;
  importance?: string;
  isRead?: boolean;
}

async function pollForNewMessages(env: Env): Promise<number> {
  const mailbox = env.MS365_MAILBOX;
  const userPath = `/users/${encodeURIComponent(mailbox)}`;

  // Load delta state from KV
  const deltaRaw = await env.EMAILS.get("state:delta");
  let deltaLink: string | null = deltaRaw ? JSON.parse(deltaRaw).deltaLink : null;

  // Load seen IDs
  const seenRaw = await env.EMAILS.get("state:seen");
  const seenIds: string[] = seenRaw ? JSON.parse(seenRaw) : [];
  const seenSet = new Set(seenIds);

  // Load sent-by-agent tracking
  const sentRaw = await env.EMAILS.get("state:sent");
  const sentIds: string[] = sentRaw ? JSON.parse(sentRaw) : [];
  const sentSet = new Set(sentIds);

  let url: string;
  if (deltaLink) {
    url = deltaLink;
  } else {
    // Initial sync: get recent messages (last 24h)
    const since = new Date(Date.now() - 86400_000).toISOString();
    url = `${GRAPH_BASE}${userPath}/mailFolders/inbox/messages/delta?$select=id,subject,bodyPreview,body,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments,importance,conversationId,isRead&$filter=receivedDateTime ge ${since}&$top=25`;
  }

  let newCount = 0;

  while (url) {
    const data = await graphGet<{
      value: GraphMessage[];
      "@odata.nextLink"?: string;
      "@odata.deltaLink"?: string;
    }>(env, url);

    for (const msg of data.value) {
      if (!msg.id || !msg.from?.emailAddress?.address) continue;

      const fromAddr = msg.from.emailAddress.address.toLowerCase();

      // Anti-loop: skip messages from our own mailbox
      if (fromAddr === mailbox.toLowerCase()) continue;

      // Skip already-seen
      if (seenSet.has(msg.id)) continue;

      // Skip messages we sent
      if (sentSet.has(msg.id)) continue;

      // Queue the message
      const queued: QueuedEmail = {
        ts: new Date().toISOString(),
        messageId: msg.id,
        conversationId: msg.conversationId ?? "",
        from: fromAddr,
        fromName: msg.from.emailAddress.name ?? fromAddr,
        to: (msg.toRecipients ?? []).map((r) => r.emailAddress.address),
        cc: (msg.ccRecipients ?? []).map((r) => r.emailAddress.address),
        subject: msg.subject ?? "(no subject)",
        bodyPreview: msg.bodyPreview ?? "",
        body: msg.body?.contentType === "html"
          ? htmlToText(msg.body.content ?? "")
          : (msg.body?.content ?? msg.bodyPreview ?? ""),
        hasAttachments: msg.hasAttachments ?? false,
        receivedAt: msg.receivedDateTime ?? new Date().toISOString(),
        importance: msg.importance ?? "normal",
      };

      const key = `msg:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
      await env.EMAILS.put(key, JSON.stringify(queued), { expirationTtl: 86400 * 7 });

      // Update index
      const indexRaw = await env.EMAILS.get("msg:index");
      const index: string[] = indexRaw ? JSON.parse(indexRaw) : [];
      index.push(key);
      if (index.length > 500) index.splice(0, index.length - 500);
      await env.EMAILS.put("msg:index", JSON.stringify(index));

      // Mark as seen
      seenSet.add(msg.id);

      // Mark as read in Graph
      try {
        await graphPatch(env, `${userPath}/messages/${encodeURIComponent(msg.id)}`, { isRead: true });
      } catch { /* best effort */ }

      newCount++;
    }

    if (data["@odata.deltaLink"]) {
      deltaLink = data["@odata.deltaLink"];
      url = "";
    } else if (data["@odata.nextLink"]) {
      url = data["@odata.nextLink"];
    } else {
      url = "";
    }
  }

  // Save delta state
  await env.EMAILS.put("state:delta", JSON.stringify({ deltaLink, lastPoll: new Date().toISOString() }));

  // Save seen IDs (keep last 2000)
  const seenArr = Array.from(seenSet);
  if (seenArr.length > 2000) seenArr.splice(0, seenArr.length - 2000);
  await env.EMAILS.put("state:seen", JSON.stringify(seenArr));

  return newCount;
}

// -------------------------------------------------------------------------
// API handlers
// -------------------------------------------------------------------------

function checkAuth(request: Request, env: Env): boolean {
  return request.headers.get("X-API-Key") === env.API_KEY;
}

async function handleApi(request: Request, url: URL, env: Env): Promise<Response> {
  if (!checkAuth(request, env)) {
    return new Response("Unauthorized", { status: 401 });
  }

  const mailbox = env.MS365_MAILBOX;
  const userPath = `/users/${encodeURIComponent(mailbox)}`;

  // GET /api/poll - drain queued messages
  if (url.pathname === "/api/poll" && request.method === "GET") {
    const indexRaw = await env.EMAILS.get("msg:index");
    const index: string[] = indexRaw ? JSON.parse(indexRaw) : [];
    const messages: QueuedEmail[] = [];

    for (const key of index) {
      const raw = await env.EMAILS.get(key);
      if (raw) {
        messages.push(JSON.parse(raw) as QueuedEmail);
        await env.EMAILS.delete(key);
      }
    }

    await env.EMAILS.put("msg:index", JSON.stringify([]));

    return json({ messages, count: messages.length });
  }

  // POST /api/reply - reply to an email
  if (url.pathname === "/api/reply" && request.method === "POST") {
    const body = (await request.json()) as { messageId: string; body: string; cc?: string };
    if (!body.messageId || !body.body) {
      return json({ error: "messageId and body required" }, 400);
    }

    const replyBody: Record<string, unknown> = {
      message: {
        body: { contentType: "html", content: body.body },
      },
      comment: "",
    };
    if (body.cc) {
      (replyBody.message as Record<string, unknown>).ccRecipients = body.cc.split(",").map((a) => ({
        emailAddress: { address: a.trim() },
      }));
    }

    await graphPost(env, `${userPath}/messages/${encodeURIComponent(body.messageId)}/reply`, replyBody);

    // Track sent message to prevent echo
    const sentRaw = await env.EMAILS.get("state:sent");
    const sentIds: string[] = sentRaw ? JSON.parse(sentRaw) : [];
    sentIds.push(body.messageId);
    if (sentIds.length > 500) sentIds.splice(0, sentIds.length - 500);
    await env.EMAILS.put("state:sent", JSON.stringify(sentIds));

    return json({ ok: true });
  }

  // POST /api/send - compose and send a new email
  if (url.pathname === "/api/send" && request.method === "POST") {
    const body = (await request.json()) as { to: string; subject: string; body: string; cc?: string };
    if (!body.to || !body.subject || !body.body) {
      return json({ error: "to, subject, and body required" }, 400);
    }

    const message: Record<string, unknown> = {
      subject: body.subject,
      body: { contentType: "html", content: body.body },
      toRecipients: body.to.split(",").map((a) => ({ emailAddress: { address: a.trim() } })),
    };
    if (body.cc) {
      message.ccRecipients = body.cc.split(",").map((a) => ({ emailAddress: { address: a.trim() } }));
    }

    await graphPost(env, `${userPath}/sendMail`, { message, saveToSentItems: true });
    return json({ ok: true });
  }

  // POST /api/forward - forward an email
  if (url.pathname === "/api/forward" && request.method === "POST") {
    const body = (await request.json()) as { messageId: string; to: string; comment?: string };
    if (!body.messageId || !body.to) {
      return json({ error: "messageId and to required" }, 400);
    }

    await graphPost(env, `${userPath}/messages/${encodeURIComponent(body.messageId)}/forward`, {
      toRecipients: body.to.split(",").map((a) => ({ emailAddress: { address: a.trim() } })),
      comment: body.comment ?? "",
    });
    return json({ ok: true });
  }

  // POST /api/flag - flag/unflag a message
  if (url.pathname === "/api/flag" && request.method === "POST") {
    const body = (await request.json()) as { messageId: string; status?: string };
    if (!body.messageId) {
      return json({ error: "messageId required" }, 400);
    }
    const flagStatus = body.status ?? "flagged";
    await graphPatch(env, `${userPath}/messages/${encodeURIComponent(body.messageId)}`, {
      flag: { flagStatus },
    });
    return json({ ok: true });
  }

  // GET /api/search?q=... - search mailbox
  if (url.pathname === "/api/search" && request.method === "GET") {
    const q = url.searchParams.get("q");
    if (!q) return json({ error: "q parameter required" }, 400);
    const top = url.searchParams.get("top") ?? "10";

    const data = await graphGet<{ value: GraphMessage[] }>(
      env,
      `${userPath}/messages?$search="${encodeURIComponent(q)}"&$top=${top}&$select=id,subject,bodyPreview,from,receivedDateTime,hasAttachments,importance`,
    );

    const results = data.value.map((m) => ({
      id: m.id,
      subject: m.subject,
      from: m.from?.emailAddress?.address,
      fromName: m.from?.emailAddress?.name,
      receivedAt: m.receivedDateTime,
      preview: m.bodyPreview,
      hasAttachments: m.hasAttachments,
    }));

    return json({ results, count: results.length });
  }

  // GET /api/thread?conversationId=... - list thread messages
  if (url.pathname === "/api/thread" && request.method === "GET") {
    const convId = url.searchParams.get("conversationId");
    if (!convId) return json({ error: "conversationId required" }, 400);

    const data = await graphGet<{ value: GraphMessage[] }>(
      env,
      `${userPath}/messages?$filter=conversationId eq '${convId}'&$orderby=receivedDateTime asc&$top=25&$select=id,subject,bodyPreview,from,receivedDateTime,body`,
    );

    const messages = data.value.map((m) => ({
      id: m.id,
      subject: m.subject,
      from: m.from?.emailAddress?.address,
      fromName: m.from?.emailAddress?.name,
      receivedAt: m.receivedDateTime,
      body: m.body?.contentType === "html" ? htmlToText(m.body.content ?? "") : m.body?.content,
    }));

    return json({ messages, count: messages.length });
  }

  // GET /api/status - polling status
  if (url.pathname === "/api/status" && request.method === "GET") {
    const deltaRaw = await env.EMAILS.get("state:delta");
    const delta = deltaRaw ? JSON.parse(deltaRaw) : null;
    const indexRaw = await env.EMAILS.get("msg:index");
    const index: string[] = indexRaw ? JSON.parse(indexRaw) : [];
    return json({
      mailbox: env.MS365_MAILBOX,
      lastPoll: delta?.lastPoll ?? null,
      hasDelta: !!delta?.deltaLink,
      pendingMessages: index.length,
    });
  }

  return new Response("Not found", { status: 404 });
}

function json(data: unknown, status = 200): Response {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

// -------------------------------------------------------------------------
// Worker entry point
// -------------------------------------------------------------------------

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/health") {
      return json({ ok: true, service: "exult-email-worker", mailbox: env.MS365_MAILBOX });
    }

    if (url.pathname.startsWith("/api/")) {
      try {
        return await handleApi(request, url, env);
      } catch (err) {
        return json({ error: String(err) }, 500);
      }
    }

    return new Response("Exult Email Worker", { status: 200 });
  },

  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
    try {
      const count = await pollForNewMessages(env);
      if (count > 0) {
        console.log(`Polled ${count} new email(s) for ${env.MS365_MAILBOX}`);
      }
    } catch (err) {
      console.error("Email poll failed:", err);
    }
  },
};
