/**
 * Exult Teams Webhook Worker
 *
 * Always-on Cloudflare Worker that receives Microsoft Teams bot webhooks,
 * validates JWT tokens, queues inbound messages to KV, and sends replies
 * via Bot Framework REST API.
 *
 * Replaces the local webhook server + Cloudflare tunnel setup.
 */

interface Env {
  MESSAGES: KVNamespace;
  MSTEAMS_APP_ID: string;
  MSTEAMS_APP_PASSWORD: string;
  MSTEAMS_TENANT_ID: string;
}

interface ConversationRef {
  serviceUrl: string;
  bot: { id: string; name: string };
  user: { id: string; name: string };
  conversationType: string;
  lastActive: string;
}

interface QueuedMessage {
  ts: string;
  content: string;
  meta: Record<string, string>;
}

// Cache bot token in memory (Workers reuse isolates)
let cachedToken: string | null = null;
let tokenExpiresAt = 0;

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

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

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

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

async function botPost(
  env: Env,
  serviceUrl: string,
  path: string,
  body: Record<string, unknown>,
): Promise<Record<string, unknown>> {
  const token = await getBotToken(env);
  const baseUrl = serviceUrl.replace(/\/+$/, "");
  const res = await fetch(`${baseUrl}${path}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    const text = await res.text().catch(() => "(no body)");
    throw new Error(`Bot POST ${path} failed (${res.status}): ${text}`);
  }

  const text = await res.text();
  return text ? (JSON.parse(text) as Record<string, unknown>) : {};
}

function stripMentionTags(text: string): string {
  return text.replace(/<at[^>]*>.*?<\/at>/gi, "").trim();
}

function normalizeConversationId(id: string): string {
  return id.replace(/^a:/, "");
}

// JWT validation: verify the token is from Bot Framework or Entra
async function validateJwt(
  authHeader: string | null,
  env: Env,
): Promise<boolean> {
  if (!authHeader?.startsWith("Bearer ")) return false;
  const token = authHeader.slice(7);

  try {
    // Decode JWT header to get kid
    const [headerB64] = token.split(".");
    const header = JSON.parse(atob(headerB64.replace(/-/g, "+").replace(/_/g, "/")));
    const kid = header.kid;
    if (!kid) return false;

    // Decode payload for issuer and audience checks
    const payloadB64 = token.split(".")[1];
    const payload = JSON.parse(
      atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")),
    );

    // Check audience matches our app
    if (payload.aud !== env.MSTEAMS_APP_ID) return false;

    // Check issuer is Bot Framework or Entra
    const validIssuers = [
      "https://api.botframework.com",
      `https://sts.windows.net/${env.MSTEAMS_TENANT_ID}/`,
      `https://login.microsoftonline.com/${env.MSTEAMS_TENANT_ID}/v2.0`,
    ];
    if (!validIssuers.some((iss) => payload.iss === iss)) return false;

    // Check expiry
    if (payload.exp && payload.exp < Date.now() / 1000) return false;

    // Fetch JWKS and verify signature
    const jwksUrls = [
      "https://login.botframework.com/v1/.well-known/keys",
      "https://login.microsoftonline.com/common/discovery/v2.0/keys",
    ];

    for (const jwksUrl of jwksUrls) {
      try {
        const jwksRes = await fetch(jwksUrl);
        if (!jwksRes.ok) continue;
        const jwks = (await jwksRes.json()) as { keys: Array<{ kid: string; x5c?: string[]; n?: string; e?: string; kty?: string }> };
        const key = jwks.keys.find((k) => k.kid === kid);
        if (!key) continue;

        // Import the key and verify
        let cryptoKey: CryptoKey;
        if (key.x5c?.[0]) {
          const certDer = Uint8Array.from(atob(key.x5c[0]), (c) => c.charCodeAt(0));
          cryptoKey = await crypto.subtle.importKey(
            "spki",
            certDer,
            { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
            false,
            ["verify"],
          );
        } else if (key.n && key.e) {
          const jwk = { kty: key.kty || "RSA", n: key.n, e: key.e, alg: "RS256" };
          cryptoKey = await crypto.subtle.importKey(
            "jwk",
            jwk,
            { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
            false,
            ["verify"],
          );
        } else {
          continue;
        }

        const parts = token.split(".");
        const signedData = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
        const signature = Uint8Array.from(
          atob(parts[2].replace(/-/g, "+").replace(/_/g, "/")),
          (c) => c.charCodeAt(0),
        );

        const valid = await crypto.subtle.verify(
          "RSASSA-PKCS1-v1_5",
          cryptoKey,
          signature,
          signedData,
        );
        if (valid) return true;
      } catch {
        continue;
      }
    }
    return false;
  } catch {
    return false;
  }
}

// Store a conversation reference in KV
async function saveConvRef(kv: KVNamespace, convId: string, activity: Record<string, unknown>): Promise<void> {
  const conversation = activity.conversation as Record<string, unknown> | undefined;
  const from = activity.from as Record<string, unknown> | undefined;
  const recipient = activity.recipient as Record<string, unknown> | undefined;

  const ref: ConversationRef = {
    serviceUrl: String(activity.serviceUrl ?? ""),
    bot: { id: String(recipient?.id ?? ""), name: String(recipient?.name ?? "") },
    user: { id: String(from?.id ?? ""), name: String(from?.name ?? "") },
    conversationType: String(conversation?.conversationType ?? "personal"),
    lastActive: new Date().toISOString(),
  };

  await kv.put(`conv:${convId}`, JSON.stringify(ref), { expirationTtl: 86400 * 30 });
}

async function getConvRef(kv: KVNamespace, convId: string): Promise<ConversationRef | null> {
  const raw = await kv.get(`conv:${convId}`);
  return raw ? (JSON.parse(raw) as ConversationRef) : null;
}

// Queue an inbound message
async function queueMessage(kv: KVNamespace, msg: QueuedMessage): Promise<void> {
  const key = `msg:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  await kv.put(key, JSON.stringify(msg), { expirationTtl: 86400 * 7 });

  // Maintain an index of message keys for easy listing
  const indexRaw = await kv.get("msg:index");
  const index: string[] = indexRaw ? JSON.parse(indexRaw) : [];
  index.push(key);
  // Keep last 500 messages
  if (index.length > 500) index.splice(0, index.length - 500);
  await kv.put("msg:index", JSON.stringify(index));
}

// Handle the Teams webhook POST
async function handleWebhook(request: Request, env: Env): Promise<Response> {
  // Validate JWT
  const authHeader = request.headers.get("Authorization");
  const isValid = await validateJwt(authHeader, env);
  if (!isValid) {
    return new Response("Unauthorized", { status: 401 });
  }

  const activity = (await request.json()) as Record<string, unknown>;
  const activityType = String(activity.type ?? "");
  const conversation = activity.conversation as Record<string, unknown> | undefined;
  const conversationId = normalizeConversationId(String(conversation?.id ?? ""));
  const from = activity.from as Record<string, unknown> | undefined;
  const senderName = String(from?.name ?? "unknown");
  const senderId = String(from?.aadObjectId ?? from?.id ?? "");
  const messageId = String(activity.id ?? "");

  // Save conversation reference
  if (conversationId) {
    await saveConvRef(env.MESSAGES, conversationId, activity);
  }

  // Handle conversationUpdate / installationUpdate (bot added)
  if (activityType === "installationUpdate" || activityType === "conversationUpdate") {
    const membersAdded = activity.membersAdded as Array<{ id?: string }> | undefined;
    const botId = (activity.recipient as Record<string, unknown> | undefined)?.id;
    const isBotAdded = activityType === "installationUpdate" ||
      (membersAdded ?? []).some((m) => m.id === botId);

    if (isBotAdded && conversationId) {
      const ref = await getConvRef(env.MESSAGES, conversationId);
      if (ref) {
        try {
          await botPost(env, ref.serviceUrl,
            `/v3/conversations/${encodeURIComponent(conversationId)}/activities`,
            {
              type: "message",
              text: "Exult Agent is online. Send me a message anytime.",
              from: { id: ref.bot.id, name: ref.bot.name },
              conversation: { id: conversationId, conversationType: ref.conversationType },
            },
          );
        } catch { /* best effort */ }
      }
    }
    return new Response(JSON.stringify({ status: "ok" }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }

  // Handle invoke activities (card actions)
  if (activityType === "invoke") {
    const submitData = activity.value as Record<string, unknown> | undefined;
    if (submitData && Object.keys(submitData).length > 0) {
      await queueMessage(env.MESSAGES, {
        ts: new Date().toISOString(),
        content: `[Card Action] ${JSON.stringify(submitData)}`,
        meta: {
          chat_id: conversationId,
          sender: senderName,
          sender_id: senderId,
          message_id: messageId,
          conversation_type: String(conversation?.conversationType ?? "personal"),
          source: "teams",
          is_card_action: "true",
          card_action_data: JSON.stringify(submitData),
        },
      });
    }
    // Invoke activities need a 200 with body
    return new Response(JSON.stringify({ status: 200 }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }

  // Only process message activities
  if (activityType !== "message") {
    return new Response(JSON.stringify({ status: "ok" }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }

  const rawText = String(activity.text ?? "");
  const strippedText = stripMentionTags(rawText);
  if (!strippedText) {
    return new Response(JSON.stringify({ status: "ok" }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }

  // Queue the message
  await queueMessage(env.MESSAGES, {
    ts: new Date().toISOString(),
    content: strippedText,
    meta: {
      chat_id: conversationId,
      sender: senderName,
      sender_id: senderId,
      message_id: messageId,
      conversation_type: String(conversation?.conversationType ?? "personal"),
      source: "teams",
    },
  });

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

// API endpoints for Claude to poll and send messages
async function handleApi(request: Request, url: URL, env: Env): Promise<Response> {
  const apiKey = request.headers.get("X-API-Key");
  if (apiKey !== env.MSTEAMS_APP_PASSWORD) {
    return new Response("Unauthorized", { status: 401 });
  }

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

    const messages: QueuedMessage[] = [];
    const remaining: string[] = [];

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

    // Clear the index (all messages drained)
    await env.MESSAGES.put("msg:index", JSON.stringify(remaining));

    return new Response(JSON.stringify({ messages, count: messages.length }), {
      headers: { "Content-Type": "application/json" },
    });
  }

  // POST /api/messages/send - send a message via Bot Framework
  if (url.pathname === "/api/messages/send" && request.method === "POST") {
    const body = (await request.json()) as { chat_id: string; text?: string; card?: Record<string, unknown> };
    const { chat_id, text, card } = body;

    if (!chat_id) {
      return new Response(JSON.stringify({ error: "chat_id required" }), { status: 400 });
    }
    if (!text && !card) {
      return new Response(JSON.stringify({ error: "text or card required" }), { status: 400 });
    }

    const ref = await getConvRef(env.MESSAGES, chat_id);
    if (!ref) {
      return new Response(JSON.stringify({ error: `No conversation ref for ${chat_id}` }), { status: 404 });
    }

    // Bot Framework needs the full conversation ID (with a: prefix for personal chats)
    const botConvId = ref.conversationType === "personal" && !chat_id.startsWith("a:") && !chat_id.includes("@")
      ? `a:${chat_id}`
      : chat_id;

    const activity: Record<string, unknown> = {
      type: "message",
      from: { id: ref.bot.id, name: ref.bot.name },
      conversation: { id: botConvId, conversationType: ref.conversationType },
    };
    if (text) activity.text = text;
    if (card) {
      activity.attachments = [{
        contentType: "application/vnd.microsoft.card.adaptive",
        content: card,
      }];
    }

    const result = await botPost(env, ref.serviceUrl,
      `/v3/conversations/${encodeURIComponent(botConvId)}/activities`,
      activity,
    );

    return new Response(JSON.stringify({ ok: true, id: result.id }), {
      headers: { "Content-Type": "application/json" },
    });
  }

  // POST /api/conversations/seed - bulk seed conversation refs
  if (url.pathname === "/api/conversations/seed" && request.method === "POST") {
    const body = (await request.json()) as Record<string, ConversationRef>;
    let count = 0;
    for (const [convId, ref] of Object.entries(body)) {
      const normId = convId.startsWith("a:") ? convId.slice(2) : convId;
      await env.MESSAGES.put(`conv:${normId}`, JSON.stringify(ref), { expirationTtl: 86400 * 30 });
      count++;
    }
    return new Response(JSON.stringify({ ok: true, seeded: count }), {
      headers: { "Content-Type": "application/json" },
    });
  }

  // GET /api/conversations - list known conversations
  if (url.pathname === "/api/conversations" && request.method === "GET") {
    const list = await env.MESSAGES.list({ prefix: "conv:" });
    const convs: Array<{ id: string; ref: ConversationRef }> = [];
    for (const key of list.keys) {
      const raw = await env.MESSAGES.get(key.name);
      if (raw) {
        convs.push({
          id: key.name.replace("conv:", ""),
          ref: JSON.parse(raw) as ConversationRef,
        });
      }
    }
    return new Response(JSON.stringify(convs), {
      headers: { "Content-Type": "application/json" },
    });
  }

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

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

    // Health check
    if (url.pathname === "/health") {
      return new Response(
        JSON.stringify({ ok: true, service: "exult-teams-worker", mode: "cloudflare-worker" }),
        { headers: { "Content-Type": "application/json" } },
      );
    }

    // Teams webhook endpoint
    if (url.pathname === "/api/messages" && request.method === "POST") {
      try {
        return await handleWebhook(request, env);
      } catch (err) {
        console.error("Webhook error:", err);
        return new Response(
          JSON.stringify({ error: String(err) }),
          { status: 500, headers: { "Content-Type": "application/json" } },
        );
      }
    }

    // Claude polling/send API
    if (url.pathname.startsWith("/api/")) {
      try {
        return await handleApi(request, url, env);
      } catch (err) {
        return new Response(
          JSON.stringify({ error: String(err) }),
          { status: 500, headers: { "Content-Type": "application/json" } },
        );
      }
    }

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