// Recap-side proxy to the relay's credit-purchase endpoints.
//
// Architecture is identical to license-purchase.js: Recap doesn't
// hold the BTCPay credentials, the relay does. Recap just forwards
// the buyer's pick to the relay and proxies the polling. The relay
// returns the BTCPay checkout URL which the Recap UI displays in a
// modal styled to match the license-purchase modal.
//
// Endpoints:
//   GET  /api/credits/packages          → relay GET /relay/credits/packages
//   POST /api/credits/buy               → relay POST /relay/credits/buy
//   GET  /api/credits/invoice/:id       → relay GET /relay/credits/invoice/:id
//
// Auth headers (X-Recap-Install-Id + Authorization Bearer LIC1-...)
// are added by this proxy, not by the buyer-side JS — keeping the
// install identity + license key out of any client-side code.

import { getRelayBaseURL } from "./relay-default.js";
import { getInstallId } from "./install-id.js";
import { getRawLicenseKey } from "./license.js";

// Multi-mode toggle. In multi mode every credit purchase is recorded
// in pending_purchases so we know WHO (signed-in user vs. anon trial
// cookie) to credit locally when the invoice settles. Single mode is
// the legacy "operator-pool only" flow — no local accounting layer,
// the relay's credits.json IS the source of truth.
const RECAP_MODE = process.env.RECAP_MODE === "multi" ? "multi" : "single";

function relayHeaders({ json = false, req = null } = {}) {
  const h = {};
  // Identity routing for the credit-purchase + credit-poll flow:
  //
  //   Pro/Max signed-in tenant (req.user.keysat_license set)
  //     → use THEIR install ID + license. The buy invoice gets
  //       stashed with THEIR license_fingerprint so the BTCPay
  //       settle-webhook credits THEIR license-keyed pool — the
  //       same pool /api/relay/status reads when displaying their
  //       balance. Without this, credits land on the operator's
  //       pool and the buyer sees their balance unchanged after
  //       paying (the bug Grant hit on 2026-05-18).
  //
  //   Anon visitor (trial cookie only) / free signed-in tenant
  //   (no license) / single-mode operator
  //     → fall back to operator identity. The operator's pool is
  //       what's being topped up; Recaps' own accounting layer
  //       (anon_trials / tenant_credits) handles the per-user
  //       attribution locally via pending_purchases.
  let installId = null;
  let licenseKey = null;
  if (req?.user?.keysat_license && req.user.synthetic_install_id) {
    installId = req.user.synthetic_install_id;
    licenseKey = req.user.keysat_license;
  }
  if (!installId) {
    try {
      const id = getInstallId();
      if (id) installId = id;
    } catch {}
  }
  if (!licenseKey) {
    try {
      const key = getRawLicenseKey();
      if (key) licenseKey = key;
    } catch {}
  }
  if (installId) h["X-Recap-Install-Id"] = installId;
  if (licenseKey) h["Authorization"] = `Bearer ${licenseKey}`;
  if (json) h["Content-Type"] = "application/json";
  return h;
}

export function setupCreditsPurchaseRoutes(app) {
  // List bundles the operator has configured. Cheap, no auth gating —
  // the buyer needs the price menu before they decide whether to pay.
  app.get("/api/credits/packages", async (_req, res) => {
    const base = getRelayBaseURL();
    if (!base) {
      return res.status(503).json({
        error: "relay_not_configured",
        message: "Relay base URL not set on this Recaps install.",
      });
    }
    try {
      // 10s timeout — was 5s, but a cold relay request from mobile
      // cellular can take 6-8s, and Safari iOS surfaces the abort as
      // a generic "Load failed" with no other info, so the buyer
      // sees an error and has to manually retry. 10s is still snappy
      // enough that a legit failure doesn't hang the UI for long.
      const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/packages`, {
        signal: AbortSignal.timeout(10000),
      });
      const text = await r.text();
      let body = null;
      try {
        body = text ? JSON.parse(text) : null;
      } catch {}
      if (!r.ok) {
        return res.status(r.status).json(body || { error: "relay_packages_failed" });
      }
      res.json(body || { packages: [] });
    } catch (err) {
      console.error(`[credits/packages] failed: ${err?.message || err}`);
      res.status(502).json({
        error: "packages_fetch_failed",
        message: (err?.message || String(err)).slice(0, 300),
      });
    }
  });

  // Initiate a purchase. Body: { credits: 1|5|10|20 }. Returns the
  // raw relay envelope (so the UI sees credits_remaining + tier +
  // result.checkout_url + result.invoice_id).
  //
  // Multi-mode: identifies the buyer (signed-in user or anon trial
  // cookie), records a pending_purchases row keyed by the invoice_id
  // the relay returns. The settle-handler (in /api/credits/invoice/:id
  // below) uses that row to know WHERE to apply the credits locally.
  //
  // Single-mode: skips the pending_purchases bookkeeping entirely;
  // the operator IS the buyer and the relay's credits.json directly
  // tracks their pool.
  app.post("/api/credits/buy", async (req, res) => {
    const base = getRelayBaseURL();
    if (!base) {
      return res
        .status(503)
        .json({ error: "relay_not_configured" });
    }
    const credits = Number(req.body?.credits);
    const returnUrl =
      typeof req.body?.return_url === "string" && req.body.return_url.startsWith("http")
        ? req.body.return_url
        : null;
    if (!Number.isFinite(credits) || credits <= 0) {
      return res
        .status(400)
        .json({ error: "credits_required" });
    }

    // Identify the buyer for multi-mode. Either a signed-in user OR
    // an anon trial cookie. If neither, attempt to auto-mint a trial
    // cookie — anon visitors who click "Buy more" from the toolbar
    // (before they've spent their pre-trial allowance) shouldn't be
    // dead-ended into a sign-in nag. Same auto-mint pattern as
    // /api/process for pre-trial visitors. Only refuse if trials are
    // disabled or the IP is over its lifetime cap.
    let buyerType = null;
    let buyerId = null;
    if (RECAP_MODE === "multi") {
      if (req.user && req.user.id) {
        buyerType = "user";
        buyerId = req.user.id;
      } else if (req.trial && req.trial.cookie_id) {
        buyerType = "anon";
        buyerId = req.trial.cookie_id;
      } else {
        // Try to mint a fresh trial cookie so the purchase has
        // somewhere to land. forceMint=true bypasses the lifetime
        // IP cap and the trials-disabled config — a paying buyer is
        // by definition not abusing a free quota, and without a
        // tracking cookie the settle handler has nowhere to credit
        // the purchase locally (the relay still credits the operator
        // pool; we just lose the visibility to apply it to this
        // specific buyer).
        try {
          const { issueIfEligible } = await import("./anon-trial.js");
          const trial = await issueIfEligible({ req, res, forceMint: true });
          if (trial) {
            buyerType = "anon";
            buyerId = trial.cookie_id;
            // Stash on req for downstream code paths
            req.trial = trial;
          }
        } catch (err) {
          console.warn(
            "[credits/buy] anon-trial mint failed:",
            err?.message || err,
          );
        }
        if (!buyerId) {
          return res.status(401).json({
            error: "buyer_unknown",
            message:
              "Couldn't create a buyer record for this purchase. Sign up for a free account so we have somewhere to credit it.",
          });
        }
      }
    }

    try {
      const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/buy`, {
        method: "POST",
        headers: relayHeaders({ json: true, req }),
        body: JSON.stringify({ credits, return_url: returnUrl || undefined }),
        signal: AbortSignal.timeout(15_000),
      });
      const text = await r.text();
      let body = null;
      try {
        body = text ? JSON.parse(text) : null;
      } catch {}
      if (!r.ok) {
        return res
          .status(r.status)
          .json(body || { error: "relay_buy_failed" });
      }

      // Record the pending purchase BEFORE we respond, so even if the
      // browser refreshes / crashes between buy + settle, the next
      // poll for this invoice id will still know who to credit.
      // Invoice id lives under result.invoice_id per the relay's
      // envelope contract (same shape license-purchase uses).
      const invoiceId =
        body?.result?.invoice_id ||
        body?.invoice_id ||
        body?.btcpay_invoice_id ||
        null;
      if (RECAP_MODE === "multi") {
        if (!invoiceId) {
          // Loud warning — without an invoice id we can't reconcile
          // on settle. Surface the response shape so we can see what
          // the relay actually returned and fix the field-name
          // assumption if this fires.
          console.warn(
            `[credits/buy] NO invoice_id in relay response — skipping pending_purchases. Top-level keys: ${Object.keys(body || {}).join(", ")} | result keys: ${Object.keys(body?.result || {}).join(", ")}`,
          );
        } else if (!buyerType || !buyerId) {
          console.warn(
            `[credits/buy] invoice ${invoiceId}: buyer identity missing — won't auto-apply on settle.`,
          );
        } else {
          try {
            const { getDb } = await import("./db.js");
            const result = getDb()
              .prepare(
                `INSERT OR IGNORE INTO pending_purchases
                   (invoice_id, buyer_type, buyer_id, credits, created_at)
                 VALUES (?, ?, ?, ?, ?)`,
              )
              .run(invoiceId, buyerType, buyerId, credits, Date.now());
            console.log(
              `[credits/buy] tracked pending purchase invoice=${invoiceId} buyer=${buyerType}:${buyerId} credits=${credits} rowsInserted=${result.changes}`,
            );
          } catch (err) {
            console.error(
              `[credits/buy] failed to record pending purchase ${invoiceId}: ${err?.message || err}`,
            );
          }
        }
      }

      res.json(body || {});
    } catch (err) {
      console.error(`[credits/buy] failed: ${err?.message || err}`);
      res.status(502).json({
        error: "purchase_failed",
        message: (err?.message || String(err)).slice(0, 300),
      });
    }
  });

  // Poll an invoice's status. Returns the relay envelope; the UI
  // reads `result.status` ("new" | "processing" | "settled" |
  // "expired" | "invalid") and refreshes when settled.
  //
  // Multi-mode side effect: when the relay reports settled, we look
  // up the matching pending_purchases row and apply the credits to
  // the right local balance. Idempotent via applied_at — if the same
  // invoice is polled multiple times after settle, only the first
  // application takes effect.
  app.get("/api/credits/invoice/:id", async (req, res) => {
    const base = getRelayBaseURL();
    if (!base) {
      return res.status(503).json({ error: "relay_not_configured" });
    }
    const id = (req.params.id || "").trim();
    if (!id) {
      return res.status(400).json({ error: "missing_invoice_id" });
    }
    try {
      const r = await fetch(
        `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(id)}`,
        {
          headers: relayHeaders({ req }),
          signal: AbortSignal.timeout(10_000),
        }
      );
      const text = await r.text();
      let body = null;
      try {
        body = text ? JSON.parse(text) : null;
      } catch {}
      if (!r.ok) {
        return res
          .status(r.status)
          .json(body || { error: "relay_poll_failed" });
      }

      // Multi-mode: settle-and-apply. Status path mirrors the
      // license-purchase poll-settle handler.
      const status =
        body?.result?.status || body?.status || null;
      if (RECAP_MODE === "multi" && status === "settled") {
        try {
          await applyPendingPurchase(id);
        } catch (err) {
          console.error(
            `[credits/invoice] apply failed for ${id}: ${err?.message || err}`,
          );
          // Don't fail the response — the relay reported settled and
          // the operator pool has the credits. Local apply can be
          // retried by hitting this endpoint again, or by a future
          // reconciliation tool.
        }
      }

      res.json(body || {});
    } catch (err) {
      console.error(`[credits/invoice] failed: ${err?.message || err}`);
      res.status(502).json({
        error: "poll_failed",
        message: (err?.message || String(err)).slice(0, 300),
      });
    }
  });

  // POST /api/credits/claim { invoice_id }
  // Manual self-service recovery: a signed-in user pastes the BTCPay
  // invoice ID of a purchase they made anonymously (e.g., Safari
  // Private mode where the trial cookie didn't survive the magic-
  // link click). We verify the invoice is settled at the relay AND
  // the pending_purchases row is anon-buyer + unapplied, then credit
  // their account.
  //
  // Safety:
  //   - Requires authenticated user (req.user.id must be set)
  //   - Only claims buyer_type='anon' rows (no user-to-user takeover)
  //   - applied_at idempotency guard prevents double-credit
  //   - BTCPay invoice IDs are 30+ char random — not enumerable
  //   - User-buyer rows are never claimable here, regardless of
  //     ownership — those are the cookie sweep's job
  app.post("/api/credits/claim", async (req, res) => {
    if (RECAP_MODE !== "multi") {
      return res.status(404).json({ error: "not_available" });
    }
    if (!req.user || !req.user.id) {
      return res.status(401).json({
        error: "auth_required",
        message: "Sign in first to claim a purchase to your account.",
      });
    }
    const invoiceId = String(req.body?.invoice_id || "").trim();
    if (!invoiceId) {
      return res.status(400).json({
        error: "missing_invoice_id",
        message: "Paste the invoice ID from your purchase email.",
      });
    }

    const { getDb } = await import("./db.js");
    const db = getDb();
    const row = db
      .prepare(
        `SELECT invoice_id, buyer_type, buyer_id, credits, applied_at
         FROM pending_purchases WHERE invoice_id = ?`,
      )
      .get(invoiceId);

    if (!row) {
      return res.status(404).json({
        error: "invoice_not_found",
        message:
          "We don't have a record of that invoice ID. Double-check it — the ID is shown in your BTCPay payment confirmation.",
      });
    }
    if (row.buyer_type !== "anon") {
      // user-buyer rows are claimable only by their original buyer
      // (cookie sweep) — refusing this avoids user-to-user takeover.
      return res.status(403).json({
        error: "not_anon_purchase",
        message:
          "This invoice was bought from a signed-in account and can only be claimed by that account.",
      });
    }
    if (row.applied_at) {
      return res.status(409).json({
        error: "already_applied",
        message:
          "Those credits were already applied. Check your balance — they may have transferred automatically.",
      });
    }

    // Verify settled at the relay before crediting. We do NOT trust
    // the local row alone — the buyer could have initiated the
    // invoice and never paid; without this check, anyone could
    // claim N credits just by knowing an invoice ID.
    const base = getRelayBaseURL();
    if (!base) {
      return res.status(503).json({ error: "relay_not_configured" });
    }
    let status = null;
    try {
      const r = await fetch(
        `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`,
        { headers: relayHeaders({ req }), signal: AbortSignal.timeout(10_000) },
      );
      if (r.ok) {
        const body = await r.json().catch(() => ({}));
        status = body?.result?.status || body?.status || null;
      }
    } catch (err) {
      console.warn(
        `[credits/claim] relay status check failed for ${invoiceId}: ${err?.message || err}`,
      );
      return res.status(502).json({
        error: "relay_unreachable",
        message:
          "Couldn't verify the invoice with the payment server. Try again in a minute.",
      });
    }
    if (status !== "settled") {
      return res.status(409).json({
        error: "not_settled",
        message: `That invoice is not settled (status: ${status || "unknown"}). If you just paid, wait a minute and try again.`,
      });
    }

    try {
      await applyPendingPurchase(invoiceId, { forceUserId: req.user.id });
    } catch (err) {
      console.error(
        `[credits/claim] apply failed for ${invoiceId}: ${err?.message || err}`,
      );
      return res.status(500).json({
        error: "apply_failed",
        message: "Something went wrong applying the credits. Try again.",
      });
    }
    console.log(
      `[credits/claim] user ${req.user.id} claimed invoice ${invoiceId} (${row.credits} credits)`,
    );
    res.json({ ok: true, credits: row.credits });
  });
}

// applyPendingPurchase(invoiceId, opts?) — credit the buyer's local
// balance for a settled invoice. Idempotent: bails if the row is
// already marked applied. If the buyer was an anon trial that has
// since been converted to a real user, credits route to the user
// instead.
//
// opts.forceUserId (optional) — route credits to this user instead
// of the row's recorded buyer. Used by the manual-claim endpoint:
// when a signed-in user pastes a BTCPay invoice ID for an anon
// purchase whose trial cookie was lost (e.g., Safari Private mode
// where the magic-link click landed in a different cookie jar), we
// trust the invoice ID as proof-of-ownership and direct the credits
// to their tenant_credits.
//
// Exported so the sweep helper below — and any future server-side
// flow that wants to reconcile a known-settled invoice — can call it
// without going through the /api/credits/invoice/:id route.
export async function applyPendingPurchase(invoiceId, opts = {}) {
  const { getDb } = await import("./db.js");
  const db = getDb();
  const row = db
    .prepare(
      `SELECT invoice_id, buyer_type, buyer_id, credits, applied_at
       FROM pending_purchases WHERE invoice_id = ?`,
    )
    .get(invoiceId);
  if (!row) {
    // Either the buy came from a different Recap instance, or the
    // bookkeeping insert in /api/credits/buy failed earlier. Nothing
    // to do; the operator pool still has the credits from BTCPay.
    // Log so operator can reconcile manually if this fires.
    console.warn(
      `[credits/invoice] settled invoice ${invoiceId} has NO matching pending_purchases row — local balance NOT auto-applied. The credits ARE in the operator pool at the relay; operator should grant manually to the buyer.`,
    );
    return;
  }
  if (row.applied_at) {
    return; // already applied, idempotent no-op
  }

  // Resolve buyer → target user_id (for tenant_credits) or trial
  // cookie_id (for anon_trials.credits_total). Anon-buyers who have
  // since converted to a real user get their credits routed to the
  // user's tenant_credits — that's the cleaner outcome and matches
  // the "credits transfer on signup" semantics the design promises.
  let targetUserId = null;
  let targetCookieId = null;
  if (opts.forceUserId) {
    targetUserId = opts.forceUserId;
  } else if (row.buyer_type === "user") {
    targetUserId = row.buyer_id;
  } else if (row.buyer_type === "anon") {
    const trial = db
      .prepare(
        "SELECT cookie_id, converted_to_user_id FROM anon_trials WHERE cookie_id = ?",
      )
      .get(row.buyer_id);
    if (trial?.converted_to_user_id) {
      targetUserId = trial.converted_to_user_id;
    } else {
      targetCookieId = row.buyer_id;
    }
  }

  // Apply + mark applied in one transaction so a crash mid-way
  // doesn't leave a half-credited buyer. Purchased credits land in
  // the PERMANENT bucket (purchased_balance) so they're not wiped on
  // the next replenishment refresh.
  const tx = db.transaction(() => {
    if (targetUserId) {
      const existing = db
        .prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?")
        .get(targetUserId);
      if (existing) {
        db.prepare(
          `UPDATE tenant_credits
           SET purchased_balance = purchased_balance + ?,
               lifetime_granted = lifetime_granted + ?
           WHERE user_id = ?`,
        ).run(row.credits, row.credits, targetUserId);
      } else {
        db.prepare(
          `INSERT INTO tenant_credits
             (user_id, purchased_balance, replenish_balance, last_replenish_at,
              lifetime_granted, lifetime_consumed)
           VALUES (?, ?, 0, ?, ?, 0)`,
        ).run(targetUserId, row.credits, Date.now(), row.credits);
      }
    } else if (targetCookieId) {
      // Anon trial: credits go into the trial's credits_total (single
      // bucket — anons don't have the purchased/replenish split).
      // They'll move to purchased_balance on signup via linkToUser.
      db.prepare(
        `UPDATE anon_trials
         SET credits_total = credits_total + ?
         WHERE cookie_id = ?`,
      ).run(row.credits, targetCookieId);
    }
    db.prepare(
      "UPDATE pending_purchases SET applied_at = ? WHERE invoice_id = ?",
    ).run(Date.now(), invoiceId);
  });
  tx();
  console.log(
    `[credits/invoice] applied ${row.credits} credits for ${row.buyer_type}:${row.buyer_id} → ${
      targetUserId ? "user " + targetUserId : "anon " + targetCookieId
    }`,
  );
}

// sweepUnappliedPurchases({ buyerType, buyerId, cookieIds }) — catch
// up on settled-but-unapplied purchases for a buyer.
//
// Why this exists: the buy → BTCPay → settle → apply pipeline depends
// on the buyer's browser tab polling /api/credits/invoice/:id after
// BTCPay redirects back. But BTCPay redirects in the SAME tab (the
// poll loop dies before it gets a chance to see "settled"), and even
// when the redirect lands back on Recap the buyer might close it
// before the next poll tick. Result: the relay knows the invoice is
// settled and the operator pool has the credits, but the LOCAL
// pending_purchases row never flips to applied — so the buyer's
// balance stays stale until they manually re-poll, which they have no
// way to do.
//
// Fix: opportunistically sweep on every /api/account/whoami and
// /api/relay/status. Cheap (small bounded query + a few relay HTTP
// calls), idempotent (applyPendingPurchase no-ops on already-applied
// rows), and self-healing.
//
// Also called from anon-trial.js linkToUser BEFORE the transfer, so
// any anon-bought credits that hadn't yet been applied locally are
// rolled into anon_trials.credits_total before we copy them over to
// the new user's tenant_credits.
//
// Scope: only sweeps the buyer's OWN pending rows. cookieIds is an
// optional list of additional anon cookie_ids the caller wants
// swept on this buyer's behalf (used by /whoami for the new-signup
// case where the just-converted cookie may still have unapplied
// purchases). Cap at 5 invoices per sweep + 30-minute lookback so a
// degenerate case can't fan out into hundreds of relay calls per
// request.
export async function sweepUnappliedPurchases({
  buyerType,
  buyerId,
  cookieIds = [],
  req = null,
} = {}) {
  if (RECAP_MODE !== "multi") return;
  if (!buyerType && (!cookieIds || cookieIds.length === 0)) return;
  const base = getRelayBaseURL();
  if (!base) return; // no relay configured, nothing to sweep against

  const { getDb } = await import("./db.js");
  const db = getDb();

  // 30-minute lookback. Older unapplied purchases probably failed for
  // a reason we don't want to keep retrying every page-load (relay
  // unreachable, invoice expired, etc.). Operator can reconcile
  // manually if they fire.
  const since = Date.now() - 30 * 60 * 1000;

  // Build the WHERE clause. Always include the primary buyer; OR in
  // any extra cookieIds the caller passed.
  const conditions = [];
  const params = [];
  if (buyerType && buyerId) {
    conditions.push("(buyer_type = ? AND buyer_id = ?)");
    params.push(buyerType, buyerId);
  }
  for (const cid of cookieIds) {
    if (typeof cid === "string" && cid) {
      conditions.push("(buyer_type = 'anon' AND buyer_id = ?)");
      params.push(cid);
    }
  }
  if (conditions.length === 0) return;
  params.push(since);

  let rows = [];
  try {
    rows = db
      .prepare(
        `SELECT invoice_id FROM pending_purchases
         WHERE (${conditions.join(" OR ")})
           AND applied_at IS NULL
           AND created_at >= ?
         ORDER BY created_at DESC
         LIMIT 5`,
      )
      .all(...params);
  } catch (err) {
    console.warn(
      `[credits/sweep] query failed: ${err?.message || err}`,
    );
    return;
  }
  if (rows.length === 0) return;

  for (const { invoice_id: invoiceId } of rows) {
    try {
      const r = await fetch(
        `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`,
        {
          headers: relayHeaders({ req }),
          signal: AbortSignal.timeout(5_000),
        },
      );
      if (!r.ok) continue;
      const text = await r.text();
      let body = null;
      try {
        body = text ? JSON.parse(text) : null;
      } catch {}
      const status = body?.result?.status || body?.status || null;
      if (status === "settled") {
        await applyPendingPurchase(invoiceId);
      }
    } catch (err) {
      // Best-effort; swallow per-invoice errors so one bad invoice
      // doesn't block the others (or the page-load).
      console.warn(
        `[credits/sweep] invoice ${invoiceId} check failed: ${err?.message || err}`,
      );
    }
  }
}
