// Tender POS companion webhook sender.
//
// Copy this file to `src/lib/tender-pos-webhook.ts` in your Medusa project.
// It is the only file the subscribers in ../subscribers/ depend on — it owns
// signing, delivery, and the retry policy. See ../INSTALL.md for setup.
//
// Pinned against Medusa 2.13.6 (@medusajs/framework 2.13.6). Uses Node's
// built-in `crypto` and global `fetch` — no extra dependencies.
import { createHash, createHmac, randomUUID } from "node:crypto";

// ---------------------------------------------------------------------------
// Configuration (environment variables)
// ---------------------------------------------------------------------------
//
// POS_WEBHOOK_URL       (required) Full URL of Tender POS's Medusa webhook
//                        endpoint, e.g. https://api.tenderpos.io/webhooks/commerce/medusa
// POS_WEBHOOK_SECRET     (required) The same secret you entered as
//                        "Webhook signing secret" when connecting Medusa in
//                        the Tender POS dashboard.
// POS_WEBHOOK_ACCOUNT_HANDLE (optional) Overrides the account-handle header
//                        below. Only set this if BACKEND_PUBLIC_URL /
//                        RAILWAY_PUBLIC_DOMAIN_VALUE aren't set in your
//                        environment (see accountHandle() below).
//
// account handle: Tender POS looks up which merchant connection a delivery
// belongs to by the HOST of your Medusa backend's public URL — the exact
// same value it already stored as the "Medusa API URL" host when you
// connected Medusa. Getting this wrong means every delivery is silently
// ignored (logged by pos-api as "ignored_unknown_account", never a 4xx/5xx
// you'd notice here), so INSTALL.md has you verify it explicitly.
const MAX_ATTEMPTS = 3;
const RETRY_DELAYS_MS = [250, 750];
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_RETRY_AFTER_MS = 10_000;
const SIGNATURE_VERSION = "1";

export type TenderPosWebhookData =
  | { resourceType: "product"; id: string }
  | { resourceType: "variant"; productId: string }
  | { resourceType: "product_option"; productId: string }
  | { resourceType: "inventory_item"; inventoryItemId: string }
  | {
      resourceType: "inventory_level";
      locationId: string;
      inventoryItemId: string;
    }
  | { resourceType: "order"; id: string }
  | { resourceType: "payment"; orderId: string }
  | { resourceType: "fulfillment"; orderId: string }
  | { resourceType: "customer"; id: string };

type MinimalLogger = {
  info: (message: string) => void;
  warn: (message: string) => void;
  error: (message: string) => void;
};

type DeliveryResponse = Pick<Response, "ok" | "status" | "headers">;

export type TenderPosWebhookDependencies = {
  fetch?: (
    input: string,
    init: RequestInit,
  ) => Promise<DeliveryResponse>;
  sleep?: (ms: number) => Promise<void>;
  randomUUID?: () => string;
  now?: () => Date;
  webhookUrl?: string;
  webhookSecret?: string;
  accountHandle?: string;
};

export async function sendTenderPosWebhook(input: {
  topic: string;
  data: TenderPosWebhookData;
  logger: MinimalLogger;
}, dependencies: TenderPosWebhookDependencies = {}): Promise<void> {
  const { topic, data, logger } = input;
  const webhookUrl =
    dependencies.webhookUrl ?? process.env.POS_WEBHOOK_URL;
  const webhookSecret =
    dependencies.webhookSecret ?? process.env.POS_WEBHOOK_SECRET;

  if (!webhookUrl || !webhookSecret) {
    logger.warn(
      `[tender-pos-webhook] POS_WEBHOOK_URL / POS_WEBHOOK_SECRET not set — skipping ${topic} delivery. ` +
        `Set both in your Medusa environment to enable Tender POS sync (see INSTALL.md).`,
    );
    return;
  }

  const handle = (
    dependencies.accountHandle ?? accountHandle()
  )?.trim().toLowerCase();
  if (!handle) {
    logger.warn(
      `[tender-pos-webhook] could not determine an account handle for ${topic} — skipping delivery. ` +
        `Set POS_WEBHOOK_ACCOUNT_HANDLE, or ensure BACKEND_PUBLIC_URL / RAILWAY_PUBLIC_DOMAIN_VALUE is set (see INSTALL.md).`,
    );
    return;
  }

  // One id per logical event, reused across every retry attempt below (never
  // regenerated per attempt) — pos-api dedupes deliveries by this id, so
  // reusing it makes retries of the SAME event idempotent instead of
  // recording duplicates.
  const eventId = (dependencies.randomUUID ?? randomUUID)();
  const triggeredAt = (dependencies.now?.() ?? new Date()).toISOString();
  const body = JSON.stringify({
    id: eventId,
    topic,
    createdAt: triggeredAt,
    data,
  });
  const signature = createHmac("sha256", webhookSecret)
    .update(
      createTenderPosWebhookSigningPayload({
        accountHandle: handle,
        topic,
        webhookId: eventId,
        triggeredAt,
        body,
      }),
    )
    .digest("base64");

  const headers: Record<string, string> = {
    "content-type": "application/json",
    "x-medusa-hmac-sha256": signature,
    "x-medusa-signature-version": SIGNATURE_VERSION,
    "x-medusa-account-handle": handle,
    "x-medusa-topic": topic,
    "x-medusa-webhook-id": eventId,
    "x-medusa-triggered-at": triggeredAt,
  };
  const fetchImpl = dependencies.fetch ?? fetch;
  const sleepImpl = dependencies.sleep ?? sleep;

  // Medusa's Redis event bus (@medusajs/event-bus-redis 2.13.6, verified
  // against the installed package) defaults every event to `attempts: 1` —
  // core commerce events (product.updated, order.placed, ...) don't override
  // this, so a thrown error here would NOT get a second delivery attempt
  // from Medusa itself. Retry the HTTP call in-process instead, then log and
  // give up — never throw, so a Tender POS outage can never fail the
  // merchant's underlying commerce operation.
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
    try {
      const response = await fetchImpl(webhookUrl, {
        method: "POST",
        headers,
        body,
        signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
      });
      if (response.ok) {
        return;
      }
      // A 401 means the secret/signature protocol is wrong — retrying an
      // identical request won't fix that, so fail fast instead of burning
      // the retry budget. An unknown account handle normally returns a
      // successful ignored_unknown_account response and must be diagnosed on
      // the Tender POS side.
      if (response.status === 401) {
        logger.error(
          `[tender-pos-webhook] ${topic} rejected with 401 — check POS_WEBHOOK_SECRET and install the current Tender POS subscriber version.`,
        );
        return;
      }
      if (!isTransientStatus(response.status)) {
        logger.error(
          `[tender-pos-webhook] ${topic} rejected with non-retryable status ${response.status} — check the callback URL and installed protocol.`,
        );
        return;
      }
      logger.warn(
        `[tender-pos-webhook] ${topic} delivery attempt ${attempt}/${MAX_ATTEMPTS} failed with ${response.status}`,
      );

      if (attempt < MAX_ATTEMPTS) {
        const retryDelay =
          retryAfterMs(response.headers.get("retry-after")) ??
          RETRY_DELAYS_MS[attempt - 1] ??
          RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]!;
        await sleepImpl(retryDelay);
      }
    } catch (error) {
      logger.warn(
        `[tender-pos-webhook] ${topic} delivery attempt ${attempt}/${MAX_ATTEMPTS} threw: ${
          error instanceof Error ? error.message : String(error)
        }`,
      );
      if (attempt < MAX_ATTEMPTS) {
        await sleepImpl(
          RETRY_DELAYS_MS[attempt - 1] ??
            RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]!,
        );
      }
    }
  }

  logger.error(
    `[tender-pos-webhook] gave up delivering ${topic} after ${MAX_ATTEMPTS} attempts (event id ${eventId})`,
  );
}

function isTransientStatus(status: number): boolean {
  return (
    status === 408 ||
    status === 425 ||
    status === 429 ||
    (status >= 500 && status <= 599)
  );
}

function retryAfterMs(value: string | null): number | null {
  if (!value) return null;
  const seconds = Number(value);
  if (Number.isFinite(seconds) && seconds >= 0) {
    return Math.min(Math.ceil(seconds * 1000), MAX_RETRY_AFTER_MS);
  }

  const timestamp = Date.parse(value);
  if (Number.isNaN(timestamp)) return null;
  return Math.min(Math.max(timestamp - Date.now(), 0), MAX_RETRY_AFTER_MS);
}

function accountHandle(): string | undefined {
  if (process.env.POS_WEBHOOK_ACCOUNT_HANDLE) {
    return process.env.POS_WEBHOOK_ACCOUNT_HANDLE;
  }
  const backendUrl =
    process.env.BACKEND_PUBLIC_URL ?? process.env.RAILWAY_PUBLIC_DOMAIN_VALUE;
  if (!backendUrl) return undefined;
  try {
    const withScheme = backendUrl.startsWith("http")
      ? backendUrl
      : `https://${backendUrl}`;
    return new URL(withScheme).host;
  } catch {
    return undefined;
  }
}

export function createTenderPosWebhookSigningPayload(input: {
  accountHandle: string;
  topic: string;
  webhookId: string;
  triggeredAt: string;
  body: string;
}): string {
  const bodyDigest = createHash("sha256").update(input.body).digest("hex");
  return [
    `tender-pos-medusa-webhook/v${SIGNATURE_VERSION}`,
    input.accountHandle,
    input.topic,
    input.webhookId,
    input.triggeredAt,
    bodyDigest,
  ].join("\n");
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
