import "@shopify/ui-extensions/preact";
import { render } from "preact";
import { BACKEND_URL_FROM_ENV } from "./backend-url.generated.js";

/**
 * Discount Rejection Monitoring.
 *
 * Observe-only. This extension never changes the cart, never blocks progress
 * and never touches discounts: it watches which codes Shopify actually
 * honours and reports the ones it does not.
 *
 * How a rejection is detected. Shopify grants a discount allocation only when
 * a code is valid, enabled, in date and applicable, so a code in
 * `discountCodes` with no allocation anywhere (order, line or delivery) was
 * not honoured. There are two shapes of rejection, and both are caught:
 *
 *  - Rejected and dropped: Shopify briefly puts the code in `discountCodes`,
 *    then removes it again and shows its own error. Caught by noticing a code
 *    that disappears having never earned an allocation.
 *  - Rejected but kept: the code stays listed with no allocation. Caught after
 *    a short settle delay, so a code is not judged while still being checked.
 */

const EXTENSION_TARGET = "purchase.checkout.reductions.render-after";
const SETTLE_DELAY_MS = 2000;
const LOG_PREFIX = "[discount-rejection-monitor]";

const REJECTION_REASONS = {
  dropped:
    "Shopify removed the code without applying it: invalid, disabled, expired or not applicable.",
  unapplied:
    "Shopify kept the code but applied no discount: not available/enabled for this checkout.",
};

function getSignalValue(signal, fallback = undefined) {
  return signal?.value ?? signal?.current ?? fallback;
}

/** "First Last", or whichever half exists, or null when neither does. */
function joinName(first, last) {
  const name = [first, last]
    .map((part) => String(part ?? "").trim())
    .filter(Boolean)
    .join(" ");

  return name || null;
}

function normalizeCode(code) {
  return String(code ?? "")
    .trim()
    .toLowerCase();
}

/** Every code that Shopify is actually applying a discount for. */
function getAppliedCodes(api) {
  const applied = new Set();

  const collect = (allocations) => {
    for (const allocation of allocations ?? []) {
      if (allocation?.type === "code" && allocation.code) {
        applied.add(normalizeCode(allocation.code));
      }
    }
  };

  collect(getSignalValue(api?.discountAllocations, []));

  for (const line of getSignalValue(api?.lines, []) ?? []) {
    collect(line?.discountAllocations);
  }

  // Free-shipping codes discount the delivery group rather than the order.
  for (const group of getSignalValue(api?.deliveryGroups, []) ?? []) {
    collect(group?.discountAllocations);

    for (const option of group?.deliveryOptions ?? []) {
      collect(option?.discountAllocations);
    }
  }

  return applied;
}

function getListedCodes(api) {
  return (getSignalValue(api?.discountCodes, []) ?? [])
    .map((entry) => String(entry?.code ?? "").trim())
    .filter(Boolean);
}

function buildCheckoutData(api) {
  const lines = getSignalValue(api?.lines, []) ?? [];
  const cost = api?.cost;

  const instructions = getSignalValue(api?.instructions, null);

  return {
    extensionTarget: EXTENSION_TARGET,
    checkoutToken: getSignalValue(api?.checkoutToken, null) ?? null,
    // Draft order invoice checkouts restrict what the buyer may edit, so these
    // are the clearest after-the-fact hint at which checkout this was.
    cartInstructions: instructions
      ? {
          canUpdateDiscountCodes:
            instructions?.discounts?.canUpdateDiscountCodes ?? null,
          canUpdateLines: instructions?.lines?.canUpdateLines ?? null,
        }
      : null,
    discountCodes: (getSignalValue(api?.discountCodes, []) ?? []).map(
      (entry) => entry?.code ?? null,
    ),
    appliedDiscountCodes: [...getAppliedCodes(api)],
    discountAllocations: (
      getSignalValue(api?.discountAllocations, []) ?? []
    ).map((allocation) => ({
      type: allocation?.type ?? null,
      code: allocation?.code ?? null,
      title: allocation?.title ?? null,
      discountedAmount: allocation?.discountedAmount ?? null,
    })),
    currency: getSignalValue(api?.localization?.currency, null)?.isoCode ?? null,
    totalAmount: getSignalValue(cost?.totalAmount, null),
    subtotalAmount: getSignalValue(cost?.subtotalAmount, null),
    lineCount: lines.length,
    lines: lines.map((line) => ({
      id: line?.id ?? null,
      variantId: line?.merchandise?.id ?? null,
      title: line?.merchandise?.title ?? null,
      quantity: line?.quantity ?? null,
    })),
  };
}

/**
 * Which checkout this extension is running on.
 *
 * Extensions do render in draft order invoice checkouts (since 2024-07), but
 * the checkout UI extension API exposes nothing that identifies one: there is
 * no draft order object and no surface flag. Rather than guess, anything
 * without a positive signal is reported as unknown, and the cart instructions
 * are attached to the log entry so a draft checkout can still be recognised
 * after the fact. Logging never depends on this: the label is the only thing
 * at stake.
 */
function detectSurface(api) {
  const draftOrder = getSignalValue(api?.draftOrder, null);

  return draftOrder ? "draft_order_checkout" : "unknown";
}

function getBackendUrl() {
  return String(BACKEND_URL_FROM_ENV || "")
    .trim()
    .replace(/\/$/, "");
}

/**
 * A Shopify-signed session token, so the backend knows which shop this
 * extension speaks for rather than taking its word for it. Null on failure.
 */
async function getSessionToken(api) {
  try {
    return (await api?.sessionToken?.get?.()) || null;
  } catch (error) {
    console.error(LOG_PREFIX, "could not get a session token", error);

    return null;
  }
}

function addressName(address) {
  if (!address) return null;

  const full = String(address.name ?? "").trim();

  return full || joinName(address.firstName, address.lastName);
}

/**
 * Who is checking out, as far as checkout currently knows.
 *
 * `buyerIdentity.customer` carries only id, email and phone - never a name -
 * so the name comes from the shipping address, then the billing address. All
 * of these are protected customer data: unless the app has been granted
 * protected customer data access for them, Shopify leaves them undefined.
 */
function readBuyer(api) {
  const customer = getSignalValue(api?.buyerIdentity?.customer, null);

  return {
    customerId: customer?.id ?? null,
    customerEmail:
      getSignalValue(api?.buyerIdentity?.email, null) || customer?.email || null,
    customerName:
      addressName(getSignalValue(api?.shippingAddress, null)) ??
      addressName(getSignalValue(api?.billingAddress, null)),
  };
}

// The last context sent, so an unchanged buyer is not re-posted on every
// checkout re-render.
let lastContextKey = null;

/**
 * Tells the backend who owns this checkout. The web pixel logs rejections
 * against the checkout token without any customer details; this is what lets
 * the server fill them in. Sent whenever the details change, since a buyer
 * usually tries a code before typing their email.
 */
async function publishCheckoutContext(api) {
  const backendUrl = getBackendUrl();
  const checkoutToken = getSignalValue(api?.checkoutToken, null);
  const buyer = readBuyer(api);

  if (!backendUrl || !checkoutToken) return;

  if (!buyer.customerId && !buyer.customerEmail && !buyer.customerName) return;

  const key = JSON.stringify([checkoutToken, buyer]);

  if (key === lastContextKey) return;

  const sessionToken = await getSessionToken(api);

  if (!sessionToken) return;

  lastContextKey = key;

  try {
    const response = await fetch(`${backendUrl}/api/discount-rejection-context`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${sessionToken}`,
      },
      keepalive: true,
      body: JSON.stringify({ checkoutToken, ...buyer }),
    });

    if (!response.ok) {
      // Let the next change retry rather than treating this as sent.
      lastContextKey = null;
      console.error(LOG_PREFIX, "backend refused checkout context", response.status);
    }
  } catch (error) {
    lastContextKey = null;
    console.error(LOG_PREFIX, "checkout context report failed", error);
  }
}

async function reportRejection(api, code, reason) {
  const backendUrl = getBackendUrl();
  const shop = api?.shop?.myshopifyDomain || "";

  if (!backendUrl || !shop) {
    console.warn(LOG_PREFIX, "no backend URL or shop; nothing reported", {
      hasBackendUrl: Boolean(backendUrl),
      shop,
    });

    return;
  }

  console.log(LOG_PREFIX, "reporting rejected code", code, reason);

  const sessionToken = await getSessionToken(api);

  if (!sessionToken) {
    console.error(LOG_PREFIX, "no session token; not reporting");

    return;
  }

  const { customerId, customerEmail, customerName } = readBuyer(api);

  try {
    const response = await fetch(`${backendUrl}/api/discount-rejection`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${sessionToken}`,
      },
      // Survives checkout navigating away mid-request.
      keepalive: true,
      body: JSON.stringify({
        shop,
        storeName: api?.shop?.name ?? null,
        discountCode: code,
        customerId,
        customerEmail,
        customerName,
        checkoutToken: getSignalValue(api?.checkoutToken, null) ?? null,
        source: "checkout_ui",
        surface: detectSurface(api),
        rejectionReason: reason,
        status: "rejected",
        checkoutData: buildCheckoutData(api),
      }),
    });

    if (!response.ok) {
      console.error(LOG_PREFIX, "backend rejected the report", response.status);
    }
  } catch (error) {
    // Monitoring must never disturb checkout.
    console.error("Discount rejection report failed", error);
  }
}

/**
 * The detection state machine, free of timers and network so it can be
 * exercised directly.
 *
 * @param {object} api      the checkout API
 * @param {(code: string, reason: string) => void} onReject
 */
export function createRejectionDetector(api, onReject) {
  // One report per code per checkout session.
  const reported = new Set();
  // Codes currently listed, and whether each was ever actually applied.
  const seen = new Map();

  function report(code, reason) {
    const key = normalizeCode(code);

    if (reported.has(key)) return;

    reported.add(key);
    onReject(code, reason);
  }

  /** A code still listed but unapplied once Shopify has settled. */
  function settle() {
    for (const [key, entry] of seen) {
      if (!entry.everApplied && !reported.has(key)) {
        report(entry.code, REJECTION_REASONS.unapplied);
      }
    }
  }

  function check() {
    const appliedCodes = getAppliedCodes(api);
    const listedCodes = getListedCodes(api);
    const listedKeys = new Set(listedCodes.map(normalizeCode));

    for (const code of listedCodes) {
      const key = normalizeCode(code);
      const entry = seen.get(key) ?? { code, everApplied: false };

      if (appliedCodes.has(key)) entry.everApplied = true;

      seen.set(key, entry);
    }

    // Shopify took the code away again. If it never earned an allocation while
    // it was listed, it was rejected - this is the usual path for a code that
    // does not exist, is disabled or has expired.
    for (const [key, entry] of [...seen]) {
      if (listedKeys.has(key)) continue;

      seen.delete(key);

      if (!entry.everApplied) report(entry.code, REJECTION_REASONS.dropped);
    }
  }

  return { check, settle };
}

export default function () {
  const api = globalThis.shopify;

  // Renders nothing: this extension is purely an observer.
  render(null, document.body);

  if (!api) return;

  const detector = createRejectionDetector(api, (code, reason) => {
    void reportRejection(api, code, reason);
  });
  let settleTimer = null;

  function onCheckoutChange() {
    detector.check();
    clearTimeout(settleTimer);
    // Wait for Shopify to finish evaluating before judging a code that stays.
    settleTimer = setTimeout(() => detector.settle(), SETTLE_DELAY_MS);
  }

  api.discountCodes?.subscribe?.(onCheckoutChange);
  api.discountAllocations?.subscribe?.(onCheckoutChange);
  api.lines?.subscribe?.(onCheckoutChange);

  // Buyer details arrive piecemeal as the form is filled in; each change is
  // debounced into one report of who owns this checkout.
  let contextTimer = null;

  function onBuyerChange() {
    clearTimeout(contextTimer);
    contextTimer = setTimeout(() => void publishCheckoutContext(api), 1500);
  }

  api.buyerIdentity?.email?.subscribe?.(onBuyerChange);
  api.buyerIdentity?.customer?.subscribe?.(onBuyerChange);
  api.shippingAddress?.subscribe?.(onBuyerChange);
  api.billingAddress?.subscribe?.(onBuyerChange);
  api.checkoutToken?.subscribe?.(onBuyerChange);

  console.log(LOG_PREFIX, "monitoring discount codes on", EXTENSION_TARGET);

  onCheckoutChange();
  onBuyerChange();
}
