/**
 * Stripe Checkout + Firestore (REST) on Cloudflare Workers.
 * Endpoints: create-checkout-session (legacy path create-payment-intent) + stripeWebhook.
 */

export interface Env {
  STRIPE_SECRET_KEY: string;
  STRIPE_WEBHOOK_SECRET: string;
  FIREBASE_PROJECT_ID: string;
  FIREBASE_SERVICE_ACCOUNT_KEY: string;
  ALLOWED_ORIGINS: string;
}

// --- Limits (aligned with functions/index.js) ---
const MAX_JSON_BODY_BYTES = 32 * 1024;
const MAX_PAYMENT_ITEMS = 50;
const MAX_PAYMENT_AMOUNT_CENTS = 250000;
const MAX_OPTION_ENTRIES_PER_ITEM = 20;
const MAX_OPTION_KEY_LENGTH = 40;
const MAX_OPTION_VALUE_LENGTH = 120;
const MAX_CONTACT_EMAIL_LENGTH = 254;

const rateLimits = new Map<string, { windowStart: number; count: number }>();

type FirestoreValue =
  | { nullValue: null }
  | { stringValue: string }
  | { integerValue: string }
  | { doubleValue: number }
  | { booleanValue: boolean }
  | { timestampValue: string }
  | { mapValue: { fields: Record<string, FirestoreValue> } }
  | { arrayValue: { values?: FirestoreValue[] } };

interface ServiceAccountJson {
  type?: string;
  project_id: string;
  private_key: string;
  client_email: string;
}

let cachedAccessToken: { token: string; exp: number } | null = null;

// --- HTTP / security ---

function getRequestIp(request: Request): string {
  const cf = request.headers.get("CF-Connecting-IP");
  if (cf) {
    return cf.trim();
  }
  const forwarded = request.headers.get("x-forwarded-for") || "";
  const first = forwarded
    .split(",")
    .map((v) => v.trim())
    .find(Boolean);
  return first || "unknown";
}

function applyIpRateLimit(
  request: Request,
  key: string,
  limit: number,
  windowMs: number
): { ok: true } | { ok: false; response: Response } {
  const ip = getRequestIp(request);
  const now = Date.now();
  const bucketKey = `${key}:${ip}`;
  const existing = rateLimits.get(bucketKey);

  if (!existing || now - existing.windowStart >= windowMs) {
    rateLimits.set(bucketKey, { windowStart: now, count: 1 });
    return { ok: true };
  }

  if (existing.count >= limit) {
    return {
      ok: false,
      response: jsonResponse(429, { error: "Too many requests" }),
    };
  }

  existing.count += 1;
  return { ok: true };
}

function setStandardSecurityHeaders(headers: Headers): void {
  headers.set("X-Content-Type-Options", "nosniff");
  headers.set("X-DNS-Prefetch-Control", "off");
  headers.set("Referrer-Policy", "no-referrer");
  headers.set("X-Frame-Options", "DENY");
  headers.set("X-XSS-Protection", "0");
  headers.set("Cross-Origin-Opener-Policy", "same-origin");
  headers.set("Cross-Origin-Resource-Policy", "cross-origin");
  headers.set(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=(), payment=()"
  );
  headers.set(
    "Strict-Transport-Security",
    "max-age=31536000; includeSubDomains; preload"
  );
  headers.set(
    "Content-Security-Policy",
    "default-src 'none'; base-uri 'none'; frame-ancestors 'none'"
  );
  headers.set("Cache-Control", "no-store");
}

function parseAllowedOrigins(raw: string): string[] {
  return String(raw || "")
    .split(",")
    .map((o) => o.trim())
    .filter(Boolean);
}

function corsHeadersForOrigin(
  request: Request,
  env: Env
): Record<string, string> {
  const origin = String(request.headers.get("origin") || "").trim();
  const allowed = parseAllowedOrigins(env.ALLOWED_ORIGINS);
  if (!origin) {
    return {};
  }
  // Empty allow-list → mirror request Origin (same idea as Cloud Functions when ALLOWED_ORIGINS is unset).
  if (!allowed.length || allowed.includes(origin)) {
    return {
      "Access-Control-Allow-Origin": origin,
      "Access-Control-Allow-Methods": "POST, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type",
      "Access-Control-Max-Age": "86400",
    };
  }
  return {};
}

function enforceAllowedOrigin(
  request: Request,
  env: Env
): { ok: true; origin: string } | { ok: false; response: Response } {
  const origin = String(request.headers.get("origin") || "").trim();
  const allowed = parseAllowedOrigins(env.ALLOWED_ORIGINS);

  if (!origin) {
    return {
      ok: false,
      response: jsonResponse(403, { error: "Origin is required" }),
    };
  }

  if (allowed.length > 0 && !allowed.includes(origin)) {
    return {
      ok: false,
      response: jsonResponse(403, { error: "Origin not allowed" }),
    };
  }

  return { ok: true, origin };
}

function jsonResponse(
  status: number,
  body: Record<string, unknown>,
  extraHeaders?: HeadersInit
): Response {
  const headers = new Headers(extraHeaders);
  headers.set("Content-Type", "application/json; charset=utf-8");
  return new Response(JSON.stringify(body), { status, headers });
}

// --- Text / validation ---

function normalizeText(value: unknown, maxLength: number): string {
  return String(value ?? "")
    .normalize("NFKC")
    // eslint-disable-next-line no-control-regex
    .replace(/[\u0000-\u001F\u007F]/g, "")
    .replace(/[<>`]/g, "")
    .trim()
    .replace(/\s+/g, " ")
    .slice(0, maxLength);
}

function isValidEmail(value: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || "").trim());
}

function isSafeIdentifier(value: unknown, maxLength = 128): boolean {
  const text = String(value ?? "").trim();
  return (
    /^[A-Za-z0-9_-]+$/.test(text) &&
    text.length > 0 &&
    text.length <= maxLength
  );
}

function sanitizeCustomerData(input: Record<string, unknown> | undefined) {
  return {
    name: normalizeText(input?.name ?? "", 160),
    email: normalizeText(input?.email ?? "", MAX_CONTACT_EMAIL_LENGTH).toLowerCase(),
    phone: normalizeText(input?.phone ?? "", 40),
    address: normalizeText(input?.address ?? "", 160),
    city: normalizeText(input?.city ?? "", 100),
    postalCode: normalizeText(input?.postalCode ?? "", 40),
    country: normalizeText(input?.country ?? "", 8).toUpperCase(),
  };
}

function sanitizeItemOptions(rawOptions: unknown): Record<string, string> | undefined {
  if (!rawOptions || typeof rawOptions !== "object" || Array.isArray(rawOptions)) {
    return undefined;
  }
  const entries = Object.entries(rawOptions as Record<string, unknown>).slice(
    0,
    MAX_OPTION_ENTRIES_PER_ITEM
  );
  const safeEntries = entries
    .map(([key, value]) => [
      normalizeText(key, MAX_OPTION_KEY_LENGTH),
      normalizeText(value, MAX_OPTION_VALUE_LENGTH),
    ])
    .filter(([key, value]) => key && value);

  return safeEntries.length ? Object.fromEntries(safeEntries) : undefined;
}

function generateFirestoreDocId(): string {
  const chars =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  const bytes = new Uint8Array(20);
  crypto.getRandomValues(bytes);
  let id = "";
  for (let i = 0; i < 20; i++) {
    id += chars[bytes[i]! % chars.length];
  }
  return id;
}

// --- Firestore REST encode/decode ---

function decodeFirestoreValue(v: FirestoreValue | undefined): unknown {
  if (!v || typeof v !== "object") {
    return null;
  }
  if ("nullValue" in v) {
    return null;
  }
  if ("stringValue" in v) {
    return v.stringValue;
  }
  if ("integerValue" in v) {
    return parseInt(v.integerValue, 10);
  }
  if ("doubleValue" in v) {
    return v.doubleValue;
  }
  if ("booleanValue" in v) {
    return v.booleanValue;
  }
  if ("timestampValue" in v) {
    return v.timestampValue;
  }
  if ("mapValue" in v && v.mapValue?.fields) {
    return decodeFirestoreFields(v.mapValue.fields);
  }
  if ("arrayValue" in v) {
    return (v.arrayValue?.values ?? []).map((x) => decodeFirestoreValue(x));
  }
  return null;
}

function decodeFirestoreFields(
  fields: Record<string, FirestoreValue>
): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const [k, val] of Object.entries(fields)) {
    out[k] = decodeFirestoreValue(val);
  }
  return out;
}

function encodeFirestoreValue(val: unknown): FirestoreValue {
  if (val === null || val === undefined) {
    return { nullValue: null };
  }
  if (val instanceof Date) {
    return { timestampValue: val.toISOString() };
  }
  if (typeof val === "string") {
    return { stringValue: val };
  }
  if (typeof val === "boolean") {
    return { booleanValue: val };
  }
  if (typeof val === "number") {
    if (Number.isInteger(val)) {
      return { integerValue: String(val) };
    }
    return { doubleValue: val };
  }
  if (Array.isArray(val)) {
    return { arrayValue: { values: val.map(encodeFirestoreValue) } };
  }
  if (typeof val === "object") {
    const fields: Record<string, FirestoreValue> = {};
    for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
      if (v === undefined) {
        continue;
      }
      fields[k] = encodeFirestoreValue(v);
    }
    return { mapValue: { fields } };
  }
  throw new Error("Unsupported Firestore value type");
}

function firestoreRoot(projectId: string): string {
  return `https://firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents`;
}

function parseServiceAccount(env: Env): ServiceAccountJson {
  const raw = String(env.FIREBASE_SERVICE_ACCOUNT_KEY || "")
    .trim()
    .replace(/^\uFEFF/, "");
  if (!raw) {
    throw new Error("Missing FIREBASE_SERVICE_ACCOUNT_KEY");
  }
  let parsed: ServiceAccountJson;
  try {
    parsed = JSON.parse(raw) as ServiceAccountJson;
  } catch (e) {
    const hint =
      e instanceof SyntaxError
        ? "Invalid FIREBASE_SERVICE_ACCOUNT_KEY JSON (truncated or broken newlines in private_key). Re-set secret: Get-Content -Raw .\\service-account.json | npx wrangler secret put FIREBASE_SERVICE_ACCOUNT_KEY"
        : "Failed to parse FIREBASE_SERVICE_ACCOUNT_KEY";
    throw new Error(hint);
  }
  if (!parsed.private_key || !parsed.client_email || !parsed.project_id) {
    throw new Error("Invalid service account JSON");
  }
  if (parsed.project_id !== env.FIREBASE_PROJECT_ID) {
    throw new Error("FIREBASE_PROJECT_ID must match service account project_id");
  }
  return parsed;
}

function pemToPkcs8ArrayBuffer(pem: string): ArrayBuffer {
  const b64 = pem
    .replace(/-----BEGIN PRIVATE KEY-----/g, "")
    .replace(/-----END PRIVATE KEY-----/g, "")
    .replace(/\s/g, "");
  const binary = atob(b64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes.buffer;
}

async function createServiceAccountJwt(sa: ServiceAccountJson): Promise<string> {
  const header = { alg: "RS256", typ: "JWT" };
  const iat = Math.floor(Date.now() / 1000);
  const exp = iat + 3600;
  const payload = {
    iss: sa.client_email,
    sub: sa.client_email,
    aud: "https://oauth2.googleapis.com/token",
    iat,
    exp,
    scope: "https://www.googleapis.com/auth/datastore",
  };

  const enc = (obj: object) => base64urlUtf8(JSON.stringify(obj));
  const headerB64 = enc(header);
  const payloadB64 = enc(payload);
  const signingInput = `${headerB64}.${payloadB64}`;

  const key = await crypto.subtle.importKey(
    "pkcs8",
    pemToPkcs8ArrayBuffer(sa.private_key),
    { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
    false,
    ["sign"]
  );

  const sigBuf = await crypto.subtle.sign(
    "RSASSA-PKCS1-v1_5",
    key,
    new TextEncoder().encode(signingInput)
  );
  const sigB64 = base64url(new Uint8Array(sigBuf));
  return `${signingInput}.${sigB64}`;
}

function base64urlUtf8(s: string): string {
  return base64url(new TextEncoder().encode(s));
}

function base64url(bytes: Uint8Array): string {
  let binary = "";
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i]!);
  }
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

async function getGoogleAccessToken(env: Env): Promise<string> {
  const nowSec = Date.now() / 1000;
  if (cachedAccessToken && cachedAccessToken.exp > nowSec + 60) {
    return cachedAccessToken.token;
  }

  const sa = parseServiceAccount(env);
  const assertion = await createServiceAccountJwt(sa);

  const res = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
      assertion,
    }),
  });

  if (!res.ok) {
    const t = await res.text();
    throw new Error(`OAuth token failed: ${res.status} ${t}`);
  }

  const data = (await res.json()) as {
    access_token: string;
    expires_in: number;
  };
  cachedAccessToken = {
    token: data.access_token,
    exp: nowSec + data.expires_in,
  };
  return data.access_token;
}

async function firestoreFetch(
  env: Env,
  pathAndQuery: string,
  init?: RequestInit
): Promise<Response> {
  const token = await getGoogleAccessToken(env);
  const url = `${firestoreRoot(env.FIREBASE_PROJECT_ID)}${pathAndQuery}`;
  const headers = new Headers(init?.headers);
  headers.set("Authorization", `Bearer ${token}`);
  if (!headers.has("Content-Type") && init?.body) {
    headers.set("Content-Type", "application/json");
  }
  return fetch(url, { ...init, headers });
}

async function batchGetProducts(
  env: Env,
  productIds: string[]
): Promise<Map<string, { price: number }>> {
  const pid = env.FIREBASE_PROJECT_ID;
  const names = productIds.map(
    (id) =>
      `projects/${pid}/databases/(default)/documents/products/${encodeURIComponent(id)}`
  );

  const res = await firestoreFetch(env, ":batchGet", {
    method: "POST",
    body: JSON.stringify({ documents: names }),
  });

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

  // batchGet returns a stream of JSON objects (often a JSON array of
  // { found?: Document, missing?: string } — NOT { found: Document[] }).
  const rawText = await res.text();
  const rows: Array<{ found?: { name?: string; fields?: Record<string, FirestoreValue> } }> =
    [];

  const pushParsed = (parsed: unknown) => {
    if (Array.isArray(parsed)) {
      for (const item of parsed) {
        if (item && typeof item === "object" && "found" in item) {
          rows.push(item as { found?: { name?: string; fields?: Record<string, FirestoreValue> } });
        }
      }
      return;
    }
    if (parsed && typeof parsed === "object" && "found" in parsed) {
      rows.push(parsed as { found?: { name?: string; fields?: Record<string, FirestoreValue> } });
    }
  };

  try {
    pushParsed(JSON.parse(rawText));
  } catch {
    for (const line of rawText.trim().split(/\n+/)) {
      if (!line.trim()) {
        continue;
      }
      try {
        pushParsed(JSON.parse(line));
      } catch {
        /* skip bad line */
      }
    }
  }

  const priceById = new Map<string, { price: number }>();
  for (const row of rows) {
    const doc = row.found;
    if (!doc?.name || !doc.fields) {
      continue;
    }
    const id = doc.name.split("/").pop() ?? "";
    const decoded = decodeFirestoreFields(doc.fields);
    const price = Number(decoded.price);
    if (Number.isFinite(price) && price >= 0) {
      priceById.set(id, { price });
    }
  }
  return priceById;
}

async function createOrderDocument(
  env: Env,
  orderId: string,
  data: Record<string, unknown>
): Promise<void> {
  const fields = encodeFirestoreValue(data) as {
    mapValue: { fields: Record<string, FirestoreValue> };
  };
  const res = await firestoreFetch(
    env,
    `/orders?documentId=${encodeURIComponent(orderId)}`,
    {
      method: "POST",
      body: JSON.stringify({ fields: fields.mapValue.fields }),
    }
  );
  if (!res.ok) {
    const t = await res.text();
    throw new Error(`create order failed: ${res.status} ${t}`);
  }
}

async function patchOrder(
  env: Env,
  orderId: string,
  patch: Record<string, unknown>,
  fieldPaths: string[]
): Promise<void> {
  const fields = encodeFirestoreValue(patch) as {
    mapValue: { fields: Record<string, FirestoreValue> };
  };
  const mask = fieldPaths
    .map((f) => `updateMask.fieldPaths=${encodeURIComponent(f)}`)
    .join("&");
  const res = await firestoreFetch(env, `/orders/${encodeURIComponent(orderId)}?${mask}`, {
    method: "PATCH",
    body: JSON.stringify({ fields: fields.mapValue.fields }),
  });
  if (!res.ok) {
    const t = await res.text();
    throw new Error(`patch order failed: ${res.status} ${t}`);
  }
}

async function getOrder(
  env: Env,
  orderId: string
): Promise<Record<string, unknown> | null> {
  const res = await firestoreFetch(env, `/orders/${encodeURIComponent(orderId)}`, {
    method: "GET",
  });
  if (res.status === 404) {
    return null;
  }
  if (!res.ok) {
    const t = await res.text();
    throw new Error(`get order failed: ${res.status} ${t}`);
  }
  const doc = (await res.json()) as {
    fields?: Record<string, FirestoreValue>;
  };
  if (!doc.fields) {
    return null;
  }
  return decodeFirestoreFields(doc.fields);
}

// --- Trusted cart (shop) ---

interface NormalizedShopItem {
  id: string;
  name: string;
  quantity: number;
  options?: Record<string, string>;
}

async function calculateTrustedAmountCents(
  env: Env,
  rawItems: unknown
): Promise<{
  totalCents: number;
  normalizedItems: NormalizedShopItem[];
  pricedLineItems: Array<{
    name: string;
    quantity: number;
    unitAmountCents: number;
  }>;
}> {
  const items = Array.isArray(rawItems) ? rawItems : [];

  if (!items.length || items.length > MAX_PAYMENT_ITEMS) {
    throw new Error("Invalid items payload");
  }

  const normalizedItems: NormalizedShopItem[] = items.map((item: unknown) => {
    const it = item as Record<string, unknown>;
    return {
      id: String(it?.id ?? "").trim(),
      name: normalizeText(it?.name ?? "", 160),
      quantity: Number(it?.quantity),
      options: sanitizeItemOptions(it?.options),
    };
  });

  if (
    normalizedItems.some(
      (item) =>
        !isSafeIdentifier(item.id, 96) ||
        !Number.isInteger(item.quantity) ||
        item.quantity <= 0 ||
        item.quantity > 20
    )
  ) {
    throw new Error("Invalid item details");
  }

  const uniqueIds = [...new Set(normalizedItems.map((item) => item.id))];
  const priceById = await batchGetProducts(env, uniqueIds);

  let totalCents = 0;
  const pricedLineItems: Array<{
    name: string;
    quantity: number;
    unitAmountCents: number;
  }> = [];

  for (const item of normalizedItems) {
    const p = priceById.get(item.id);
    const unitPrice = p?.price;
    if (!Number.isFinite(unitPrice)) {
      throw new Error("Unknown product in cart");
    }
    const unitAmountCents = Math.round(unitPrice! * 100);
    totalCents += unitAmountCents * item.quantity;
    pricedLineItems.push({
      name: item.name,
      quantity: item.quantity,
      unitAmountCents,
    });
  }

  if (totalCents <= 0 || totalCents > MAX_PAYMENT_AMOUNT_CENTS) {
    throw new Error("Invalid total amount");
  }

  const forOrder: NormalizedShopItem[] = normalizedItems.map(
    ({ id, name, quantity, options }) => ({
      id,
      name,
      quantity,
      ...(options ? { options } : {}),
    })
  );

  return { totalCents, normalizedItems: forOrder, pricedLineItems };
}

// --- Membership order ---

function buildMembershipOrderData(
  email: string,
  customerData: ReturnType<typeof sanitizeCustomerData>,
  membership: Record<string, unknown> | undefined,
  amount: unknown
) {
  const amountByPlan: Record<string, number> = {
    monthly: 700,
    annual: 6000,
  };
  const clientAmount = Number(amount);

  const safeMembershipPlan = String(membership?.plan ?? "").trim();
  const safeUserId = String(membership?.userId ?? "").trim();
  const safeEmail = normalizeText(email, MAX_CONTACT_EMAIL_LENGTH).toLowerCase();
  const safeCustomerName = customerData.name;
  const expectedAmount = amountByPlan[safeMembershipPlan];

  if (
    !Number.isFinite(clientAmount) ||
    !Number.isInteger(clientAmount) ||
    clientAmount !== expectedAmount
  ) {
    throw new Error("Invalid membership amount");
  }

  if (
    !safeMembershipPlan ||
    (safeMembershipPlan !== "monthly" && safeMembershipPlan !== "annual") ||
    !isSafeIdentifier(safeUserId, 128) ||
    !safeEmail ||
    !safeCustomerName
  ) {
    throw new Error("Invalid membership payload");
  }

  const orderId = generateFirestoreDocId();

  const orderData: Record<string, unknown> = {
    id: orderId,
    email: safeEmail,
    customerData: {
      ...customerData,
      name: safeCustomerName,
      email: safeEmail,
    },
    items: [
      {
        id: `membership-${safeMembershipPlan}`,
        name:
          safeMembershipPlan === "monthly"
            ? "Quota Mensal de Sócio"
            : "Quota Anual de Sócio",
        quantity: 1,
        price: expectedAmount / 100,
        plan: safeMembershipPlan,
        userId: safeUserId,
      },
    ],
    amount: expectedAmount,
    currency: "eur",
    status: "pending",
    purchaseType: "membership",
    membership: {
      plan: safeMembershipPlan,
      userId: safeUserId,
      email: safeEmail,
      displayName: safeCustomerName,
      phone: normalizeText(membership?.phone ?? "", 40),
    },
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };

  return {
    orderId,
    orderData,
    totalCents: expectedAmount,
    safeEmail,
    safeMembershipPlan,
    userId: safeUserId,
  };
}

// --- Stripe API (fetch) ---

function appendCheckoutLineItem(
  params: Record<string, string>,
  index: number,
  name: string,
  unitAmountCents: number,
  quantity: number
): void {
  const p = `line_items[${index}]`;
  params[`${p}[price_data][currency]`] = "eur";
  params[`${p}[price_data][unit_amount]`] = String(unitAmountCents);
  params[`${p}[price_data][product_data][name]`] = name.slice(0, 255);
  params[`${p}[quantity]`] = String(quantity);
}

async function stripeCreateCheckoutSession(
  secretKey: string,
  params: Record<string, string>
): Promise<{
  id: string;
  url: string | null;
  payment_intent: string | null;
  payment_status: string | null;
}> {
  const body = new URLSearchParams();
  for (const [k, v] of Object.entries(params)) {
    body.append(k, v);
  }

  const res = await fetch("https://api.stripe.com/v1/checkout/sessions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${secretKey}`,
      "Content-Type": "application/x-www-form-urlencoded",
      Accept: "application/json",
    },
    body,
  });

  const json = (await res.json()) as {
    id?: string;
    url?: string | null;
    payment_intent?: string | { id?: string } | null;
    payment_status?: string | null;
    error?: { message?: string };
  };

  if (!res.ok) {
    throw new Error(json.error?.message || "Stripe checkout/sessions create failed");
  }

  if (!json.id || !json.url) {
    throw new Error("Invalid Stripe checkout session response");
  }

  let paymentIntentId: string | null = null;
  const pi = json.payment_intent;
  if (typeof pi === "string") {
    paymentIntentId = pi;
  } else if (pi && typeof pi === "object" && typeof pi.id === "string") {
    paymentIntentId = pi.id;
  }

  return {
    id: json.id,
    url: json.url ?? null,
    payment_intent: paymentIntentId,
    payment_status: json.payment_status ?? null,
  };
}

// --- Webhook signature (Stripe) ---

function timingSafeEqualString(a: string, b: string): boolean {
  if (a.length !== b.length) {
    return false;
  }
  let out = 0;
  for (let i = 0; i < a.length; i++) {
    out |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return out === 0;
}

async function verifyStripeWebhookSignature(
  rawBody: string,
  sigHeader: string | null,
  secret: string
): Promise<boolean> {
  if (!sigHeader) {
    return false;
  }

  const parts = sigHeader.split(",").map((p) => p.trim());
  let timestamp = "";
  const v1s: string[] = [];
  for (const part of parts) {
    const eq = part.indexOf("=");
    if (eq === -1) {
      continue;
    }
    const k = part.slice(0, eq);
    const v = part.slice(eq + 1);
    if (k === "t") {
      timestamp = v;
    }
    if (k === "v1") {
      v1s.push(v);
    }
  }

  if (!timestamp || !v1s.length) {
    return false;
  }

  const tsNum = Number(timestamp);
  if (!Number.isFinite(tsNum)) {
    return false;
  }
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - tsNum) > 60 * 5) {
    return false;
  }

  const signedPayload = `${timestamp}.${rawBody}`;
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );
  const mac = await crypto.subtle.sign(
    "HMAC",
    key,
    new TextEncoder().encode(signedPayload)
  );
  const expectedHex = [...new Uint8Array(mac)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  return v1s.some((v) => timingSafeEqualString(v, expectedHex));
}

// --- Membership activation (Firestore) ---

async function runQuery(
  env: Env,
  structuredQuery: Record<string, unknown>
): Promise<Array<Record<string, unknown>>> {
  const res = await firestoreFetch(env, ":runQuery", {
    method: "POST",
    body: JSON.stringify({ structuredQuery }),
  });
  if (!res.ok) {
    const t = await res.text();
    throw new Error(`runQuery failed: ${res.status} ${t}`);
  }
  return (await res.json()) as Array<Record<string, unknown>>;
}

function extractDocument(
  row: Record<string, unknown>
): { name: string; data: Record<string, unknown> } | null {
  const doc = row.document as
    | {
        name?: string;
        fields?: Record<string, FirestoreValue>;
      }
    | undefined;
  if (!doc?.name || !doc.fields) {
    return null;
  }
  return { name: doc.name, data: decodeFirestoreFields(doc.fields) };
}

async function activateMembershipAfterStripePayment(
  env: Env,
  paymentIntent: Record<string, unknown>
): Promise<void> {
  const meta = (paymentIntent.metadata ?? {}) as Record<string, string>;
  const purchaseType = String(meta.purchaseType ?? meta.type ?? "").trim();
  if (purchaseType !== "membership") {
    return;
  }

  const userId = String(meta.userId ?? "").trim();
  const plan = String(meta.plan ?? "").trim();
  if (!userId || (plan !== "monthly" && plan !== "annual")) {
    return;
  }

  let email = String(meta.email ?? (paymentIntent.receipt_email as string) ?? "")
    .trim()
    .toLowerCase();
  let displayName = "";
  let phone = "";

  const orderId = String(meta.orderId ?? "").trim();
  if (orderId) {
    const ord = await getOrder(env, orderId);
    if (ord) {
      const m = (ord.membership ?? {}) as Record<string, unknown>;
      displayName = normalizeText(String(m.displayName ?? ""), 160);
      phone = normalizeText(String(m.phone ?? ""), 40);
      const orderEmail = normalizeText(
        String(m.email ?? ord.email ?? ""),
        MAX_CONTACT_EMAIL_LENGTH
      ).toLowerCase();
      if (orderEmail) {
        email = orderEmail;
      }
      if (!displayName) {
        const cd = (ord.customerData ?? {}) as Record<string, unknown>;
        displayName = normalizeText(String(cd.name ?? ""), 160);
      }
    }
  }

  const now = new Date();
  const validFrom = now.toISOString();
  const validityDays = plan === "monthly" ? 30 : 365;
  const validUntil = new Date(
    now.getTime() + validityDays * 24 * 60 * 60 * 1000
  ).toISOString();
  const adminTs = new Date();

  const memberRows = await runQuery(env, {
    from: [{ collectionId: "members" }],
    where: {
      fieldFilter: {
        field: { fieldPath: "userId" },
        op: "EQUAL",
        value: { stringValue: userId },
      },
    },
    limit: 1,
  });

  const existing = memberRows.map(extractDocument).find(Boolean);

  if (existing) {
    const docId = existing.name.split("/").pop()!;
    const ex = existing.data;
    await patchMember(env, docId, {
      name: displayName || String(ex.name ?? "Sócio FC Foz"),
      email: email || String(ex.email ?? ""),
      phone: phone || String(ex.phone ?? ""),
      memberSince: String(ex.memberSince ?? validFrom),
      validFrom,
      validUntil,
      plan,
      status: "active",
      quotasPaid: true,
      updatedAt: adminTs,
    });
    return;
  }

  const topRows = await runQuery(env, {
    from: [{ collectionId: "members" }],
    orderBy: [
      {
        field: { fieldPath: "memberNumber" },
        direction: "DESCENDING",
      },
    ],
    limit: 1,
  });

  let nextMemberNumber = 101;
  const top = topRows.map(extractDocument).find(Boolean);
  if (top) {
    const topNum = Number(top.data.memberNumber);
    if (Number.isFinite(topNum)) {
      nextMemberNumber = Math.max(101, topNum) + 1;
    }
  }

  await createMemberDocument(env, {
    userId,
    memberNumber: nextMemberNumber,
    name: displayName || "Sócio FC Foz",
    email:
      email ||
      normalizeText(
        String(paymentIntent.receipt_email ?? ""),
        MAX_CONTACT_EMAIL_LENGTH
      ).toLowerCase(),
    phone,
    memberSince: validFrom,
    validFrom,
    validUntil,
    plan,
    status: "active",
    quotasPaid: true,
    createdAt: adminTs,
  });
}

async function patchMember(
  env: Env,
  docId: string,
  patch: Record<string, unknown>
): Promise<void> {
  const fields = encodeFirestoreValue(patch) as {
    mapValue: { fields: Record<string, FirestoreValue> };
  };
  const paths = Object.keys(patch);
  const mask = paths
    .map((f) => `updateMask.fieldPaths=${encodeURIComponent(f)}`)
    .join("&");
  const res = await firestoreFetch(
    env,
    `/members/${encodeURIComponent(docId)}?${mask}`,
    {
      method: "PATCH",
      body: JSON.stringify({ fields: fields.mapValue.fields }),
    }
  );
  if (!res.ok) {
    const t = await res.text();
    throw new Error(`patch member failed: ${res.status} ${t}`);
  }
}

async function createMemberDocument(
  env: Env,
  data: Record<string, unknown>
): Promise<void> {
  const fields = encodeFirestoreValue(data) as {
    mapValue: { fields: Record<string, FirestoreValue> };
  };
  const res = await firestoreFetch(env, "/members", {
    method: "POST",
    body: JSON.stringify({ fields: fields.mapValue.fields }),
  });
  if (!res.ok) {
    const t = await res.text();
    throw new Error(`create member failed: ${res.status} ${t}`);
  }
}

// --- Handlers ---

async function handleCreateCheckoutSession(
  request: Request,
  env: Env
): Promise<Response> {
  const headers = new Headers();
  setStandardSecurityHeaders(headers);
  Object.entries(corsHeadersForOrigin(request, env)).forEach(([k, v]) =>
    headers.set(k, v)
  );

  if (request.method === "OPTIONS") {
    return new Response(null, { status: 204, headers });
  }

  if (request.method !== "POST") {
    return jsonResponse(405, { error: "Method not allowed" }, headers);
  }

  const originCheck = enforceAllowedOrigin(request, env);
  if (!originCheck.ok) {
    const h = new Headers(originCheck.response.headers);
    setStandardSecurityHeaders(h);
    return new Response(originCheck.response.body, {
      status: originCheck.response.status,
      headers: h,
    });
  }

  const requestOrigin = originCheck.origin;

  const rl = applyIpRateLimit(
    request,
    "createCheckoutSession",
    20,
    10 * 60 * 1000
  );
  if (!rl.ok) {
    const h = new Headers(rl.response.headers);
    setStandardSecurityHeaders(h);
    return new Response(rl.response.body, {
      status: rl.response.status,
      headers: h,
    });
  }

  const ct = String(request.headers.get("content-type") ?? "").toLowerCase();
  if (!ct.includes("application/json")) {
    return jsonResponse(415, { error: "Content-Type must be application/json" }, headers);
  }

  const len = Number(request.headers.get("content-length") || 0);
  if (len > MAX_JSON_BODY_BYTES) {
    return jsonResponse(413, { error: "Payload too large" }, headers);
  }

  const secretKey = String(env.STRIPE_SECRET_KEY ?? "").trim();
  if (!secretKey) {
    return jsonResponse(500, { error: "Payment service not configured" }, headers);
  }

  let body: Record<string, unknown>;
  try {
    body = (await request.json()) as Record<string, unknown>;
  } catch {
    return jsonResponse(400, { error: "Invalid JSON" }, headers);
  }

  const {
    amount,
    email,
    userId,
    items,
    customerData,
    purchaseType,
    membership,
  } = body;

  const safeEmail = normalizeText(email, MAX_CONTACT_EMAIL_LENGTH).toLowerCase();
  const safeUserId = isSafeIdentifier(userId, 128) ? String(userId).trim() : "";
  const safeCustomerData = sanitizeCustomerData(
    customerData as Record<string, unknown> | undefined
  );
  const isMembershipPurchase =
    String(purchaseType ?? "").trim() === "membership";

  if (!amount || !safeEmail || !isValidEmail(safeEmail)) {
    return jsonResponse(400, { error: "Missing required fields" }, headers);
  }

  if (
    !safeCustomerData.name ||
    !safeCustomerData.email ||
    !isValidEmail(safeCustomerData.email)
  ) {
    return jsonResponse(400, { error: "Missing or invalid customer data" }, headers);
  }

  if (!isMembershipPurchase && !Array.isArray(items)) {
    return jsonResponse(400, { error: "Invalid cart items" }, headers);
  }

  try {
    let orderId: string;
    let orderData: Record<string, unknown>;
    let totalCents: number;
    let metadata: Record<string, string>;
    let pricedLineItems: Array<{
      name: string;
      quantity: number;
      unitAmountCents: number;
    }> | null = null;

    if (isMembershipPurchase) {
      const m = buildMembershipOrderData(
        safeEmail,
        safeCustomerData,
        membership as Record<string, unknown> | undefined,
        amount
      );
      orderId = m.orderId;
      orderData = m.orderData;
      totalCents = m.totalCents;
      metadata = {
        orderId,
        email: m.safeEmail,
        purchaseType: "membership",
        plan: m.safeMembershipPlan,
        userId: m.userId,
      };
    } else {
      const clientAmount = Number(amount);
      if (
        !Number.isFinite(clientAmount) ||
        !Number.isInteger(clientAmount) ||
        clientAmount <= 0
      ) {
        return jsonResponse(400, { error: "Invalid amount" }, headers);
      }

      const trusted = await calculateTrustedAmountCents(env, items);
      if (clientAmount !== trusted.totalCents) {
        return jsonResponse(400, { error: "Invalid cart total" }, headers);
      }

      orderId = generateFirestoreDocId();
      orderData = {
        id: orderId,
        ...(safeUserId ? { userId: safeUserId } : {}),
        email: safeEmail,
        customerData: safeCustomerData,
        items: trusted.normalizedItems,
        amount: trusted.totalCents,
        currency: "eur",
        status: "pending",
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      };

      totalCents = trusted.totalCents;
      metadata = {
        orderId,
        email: safeEmail,
        itemCount: String(trusted.normalizedItems.length),
        purchaseType: "shop",
      };
      pricedLineItems = trusted.pricedLineItems;
    }

    const successUrl = `${requestOrigin}/payment-success?session_id={CHECKOUT_SESSION_ID}`;
    const cancelUrl = isMembershipPurchase
      ? `${requestOrigin}/socios`
      : `${requestOrigin}/checkout`;

    const sessionParams: Record<string, string> = {
      mode: "payment",
      customer_email: safeEmail,
      success_url: successUrl,
      cancel_url: cancelUrl,
      client_reference_id: orderId.slice(0, 200),
    };

    for (const [k, v] of Object.entries(metadata)) {
      sessionParams[`payment_intent_data[metadata][${k}]`] = v;
    }

    let lineIndex = 0;
    if (isMembershipPurchase) {
      const firstItem = (
        Array.isArray(orderData.items) ? orderData.items : []
      )[0] as Record<string, unknown> | undefined;
      const lineName = normalizeText(
        String(firstItem?.name ?? "FC Foz Quota"),
        255
      );
      appendCheckoutLineItem(
        sessionParams,
        lineIndex,
        lineName,
        totalCents,
        1
      );
      lineIndex += 1;
    } else if (pricedLineItems?.length) {
      for (const line of pricedLineItems) {
        appendCheckoutLineItem(
          sessionParams,
          lineIndex,
          line.name,
          line.unitAmountCents,
          line.quantity
        );
        lineIndex += 1;
      }
    }

    const session = await stripeCreateCheckoutSession(secretKey, sessionParams);

    orderData.checkoutSessionId = session.id;
    // Mesmo valor que o link "Ver encomenda" e getByPaymentId no cliente (orderReference / PurchasedItems).
    orderData.paymentId = session.id;
    if (session.payment_intent) {
      orderData.paymentIntentId = session.payment_intent;
    }
    orderData.paymentIntentStatus = String(
      session.payment_status ?? "unpaid"
    );

    await createOrderDocument(env, orderId, orderData);

    const outHeaders = new Headers(headers);
    setStandardSecurityHeaders(outHeaders);
    Object.entries(corsHeadersForOrigin(request, env)).forEach(([k, v]) =>
      outHeaders.set(k, v)
    );

    if (!session.url) {
      return jsonResponse(
        500,
        { error: "Checkout session missing redirect URL" },
        headers
      );
    }

    return new Response(
      JSON.stringify({
        url: session.url,
        orderId,
      }),
      {
        status: 200,
        headers: outHeaders,
      }
    );
  } catch (e) {
    const msg = e instanceof Error ? e.message : "Unknown error";
    if (
      msg.includes("Invalid") ||
      msg.includes("Unknown product") ||
      msg.includes("items payload")
    ) {
      return jsonResponse(400, { error: msg }, headers);
    }
    console.error(
      "[create-checkout-session] error:",
      e instanceof Error ? e.message : String(e)
    );
    return jsonResponse(
      500,
      { error: "Failed to create checkout session", detail: msg },
      headers
    );
  }
}

async function handleStripeWebhook(
  request: Request,
  env: Env
): Promise<Response> {
  const headers = new Headers();
  setStandardSecurityHeaders(headers);

  if (request.method !== "POST") {
    return jsonResponse(405, { error: "Method not allowed" }, headers);
  }

  const rl = applyIpRateLimit(request, "stripeWebhook", 300, 10 * 60 * 1000);
  if (!rl.ok) {
    const h = new Headers(rl.response.headers);
    setStandardSecurityHeaders(h);
    return new Response(rl.response.body, {
      status: rl.response.status,
      headers: h,
    });
  }

  const sig = request.headers.get("stripe-signature");
  const webhookSecret = String(env.STRIPE_WEBHOOK_SECRET ?? "").trim();
  if (!sig || !webhookSecret) {
    return jsonResponse(400, { error: "Missing webhook signature" }, headers);
  }

  const rawBody = await request.text();

  const okSig = await verifyStripeWebhookSignature(
    rawBody,
    sig,
    webhookSecret
  );
  if (!okSig) {
    return jsonResponse(400, { error: "Invalid signature" }, headers);
  }

  let event: { type?: string; data?: { object?: Record<string, unknown> } };
  try {
    event = JSON.parse(rawBody) as typeof event;
  } catch {
    return jsonResponse(400, { error: "Invalid JSON" }, headers);
  }

  try {
    const paymentIntent = event.data?.object;

    if (event.type === "payment_intent.succeeded" && paymentIntent) {
      const meta = (paymentIntent.metadata ?? {}) as Record<string, string>;
      const orderId = String(meta.orderId ?? "").trim();

      if (orderId) {
        const existingOrder = await getOrder(env, orderId).catch(() => null);
        const existingPi = String(
          existingOrder?.paymentIntentStatus ?? ""
        ).toLowerCase();
        if (existingPi === "succeeded") {
          return jsonResponse(200, { received: true, skipped: true }, headers);
        }
        const purchaseType = String(meta.purchaseType ?? "").trim();
        const paidOrderStatus =
          purchaseType === "membership" ? "completed" : "processing";

        await patchOrder(env, orderId, {
          paymentIntentStatus: String(paymentIntent.status ?? ""),
          status: paidOrderStatus,
          updatedAt: new Date().toISOString(),
        }, ["paymentIntentStatus", "status", "updatedAt"]);
      }

      await activateMembershipAfterStripePayment(env, paymentIntent);
    }

    if (event.type === "payment_intent.payment_failed" && paymentIntent) {
      const orderId = String(
        (paymentIntent.metadata as Record<string, string> | undefined)?.orderId ??
          ""
      ).trim();

      if (orderId) {
        const lastErr = paymentIntent.last_payment_error as
          | { message?: string }
          | undefined;
        await patchOrder(
          env,
          orderId,
          {
            paymentIntentStatus: String(paymentIntent.status ?? ""),
            status: "failed",
            failureReason: lastErr?.message ?? "",
            updatedAt: new Date().toISOString(),
          },
          ["paymentIntentStatus", "status", "failureReason", "updatedAt"]
        );
      }
    }

    return jsonResponse(200, { received: true }, headers);
  } catch (e) {
    console.error(
      "[stripe-webhook] processing error:",
      e instanceof Error ? e.message : String(e)
    );
    return jsonResponse(500, { error: "Webhook processing failed" }, headers);
  }
}

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

    if (
      url.pathname === "/create-checkout-session" ||
      url.pathname === "/create-payment-intent"
    ) {
      return handleCreateCheckoutSession(request, env);
    }

    if (url.pathname === "/stripe-webhook") {
      return handleStripeWebhook(request, env);
    }

    return jsonResponse(404, { error: "Not found" });
  },
};
