// In-app buy flow. Proxies the Keysat public API (policies + purchase
// + poll) through Recap so the frontend renders tier cards in Recap's
// own visual style instead of being redirected to Keysat's hosted
// `/buy/<slug>` page.
//
// API shape implemented here matches the Keysat spec exactly:
//   GET  /api/license/policies         → listPublicPolicies(PRODUCT_SLUG)
//   POST /api/license/purchase         → startPurchase(PRODUCT_SLUG, opts)
//   GET  /api/license/poll/:invoiceId  → pollPurchase(invoiceId); on a
//                                        settled invoice this also
//                                        ACTIVATES the issued license
//                                        on this Recap install so the
//                                        next license-status refresh
//                                        sees the new entitlements.
//
// All three routes are open to unlicensed users — buyers need to reach
// them before they have a license, by definition.

import { Client } from "@keysat/licensing-client";
import * as license from "./license.js";
import { randomBytes } from "crypto";

const KEYSAT_BASE_URL = license.KEYSAT_BASE_URL;
const PRODUCT_SLUG = license.PRODUCT_SLUG;

// Multi-mode toggle. In single mode (the default), an activated license
// is written to /data/license.txt — operator-install-wide. In multi
// mode, the buyer is a logged-in user and the license attaches to
// THEIR users row instead. Set at module load; the file is re-imported
// by tests so module-level state isn't an issue.
const RECAP_MODE = process.env.RECAP_MODE === "multi" ? "multi" : "single";

// Lazy-init the SDK client so a missing import doesn't crash boot.
let _client = null;
function getClient() {
  if (!_client) _client = new Client(KEYSAT_BASE_URL);
  return _client;
}

// Tiny in-memory cache for the policies response. Keysat's admin can
// change tiers any time so don't cache long — 30s strikes the balance
// between "operator edit takes effect quickly" and "don't hammer the
// licensing server on every browser-side modal mount". Pair this
// with using ?refresh=1 on the client if you need to invalidate
// faster than the TTL.
const POLICIES_TTL_MS = 30 * 1000;
let _policiesCache = { at: 0, body: null };

export function setupLicensePurchaseRoutes(app, { onLicenseActivated } = {}) {
  // ── List tiers ────────────────────────────────────────────────────────────
  // We deliberately bypass the SDK's listPublicPolicies() here because
  // it strips fields we need to render the buy page: marketing_bullets,
  // marketing_bullets_position, hidden_entitlements, and
  // featured_discount. Hitting the public HTTP endpoint directly gives
  // us the full snake_case JSON with everything the Keysat hosted /buy
  // page renders, so we can match its data parity in Recap's own
  // visual style.
  app.get("/api/license/policies", async (_req, res) => {
    if (_policiesCache.body && Date.now() - _policiesCache.at < POLICIES_TTL_MS) {
      return res.json({
        keysat_base_url: KEYSAT_BASE_URL,
        product_slug: PRODUCT_SLUG,
        ..._policiesCache.body,
      });
    }
    try {
      const url = `${KEYSAT_BASE_URL.replace(/\/$/, "")}/v1/products/${encodeURIComponent(PRODUCT_SLUG)}/policies`;
      const r = await fetch(url, {
        signal: AbortSignal.timeout(8000),
      });
      if (!r.ok) {
        const body = await r.text().catch(() => "");
        throw new Error(`HTTP ${r.status}: ${body.slice(0, 200)}`);
      }
      const data = await r.json();
      _policiesCache = { at: Date.now(), body: data };
      res.json({
        keysat_base_url: KEYSAT_BASE_URL,
        product_slug: PRODUCT_SLUG,
        ...data,
      });
    } catch (err) {
      console.error(`[license/policies] failed: ${err?.message || err}`);
      res.status(502).json({
        error: "policies_fetch_failed",
        message: (err?.message || String(err)).slice(0, 300),
      });
    }
  });

  // ── Start purchase ────────────────────────────────────────────────────────
  // Body: { policySlug: string, buyerEmail?: string, code?: string,
  //         redirectUrl?: string, buyerNote?: string }
  // Returns the FULL raw Keysat /v1/purchase response, including
  // discount fields: { invoice_id, checkout_url, final_price_sats,
  // discount_applied_sats, amount_sats, btcpay_invoice_id, poll_url }.
  // We bypass the SDK (which strips final_price_sats /
  // discount_applied_sats from PurchaseSession) so the UI can show
  // "you saved N sats" before sending the buyer to BTCPay.
  //
  // Multi-mode anon-signup flow: if there's no signed-in user AND a
  // buyer_email is supplied, we treat this as a "create-account-via-
  // purchase" flow. We record a pending_signups row keyed by the
  // invoice id so the poll-settle handler can create the user + send
  // the license-ready magic-link email once payment lands.
  app.post("/api/license/purchase", async (req, res) => {
    const {
      policySlug,
      buyerEmail,
      code,
      redirectUrl,
      buyerNote,
    } = req.body || {};
    if (!policySlug || typeof policySlug !== "string") {
      return res.status(400).json({
        error: "missing_policy_slug",
        message:
          "policySlug is required — get it from /api/license/policies, never hardcode.",
      });
    }
    // Anon-signup gate: in multi-mode, if no signed-in user, an email
    // is REQUIRED — that's how the settle handler knows who to send
    // the "your account is ready" link to. Signed-in users in multi
    // mode have their email implicit via req.user.email (still passed
    // via buyerEmail prefill from the frontend), so this only blocks
    // the truly-anon-no-email edge case.
    const isAnonSignup =
      RECAP_MODE === "multi" && !req.user && !!buyerEmail;
    if (RECAP_MODE === "multi" && !req.user && !buyerEmail) {
      return res.status(400).json({
        error: "email_required",
        message:
          "Enter your email — we'll send a sign-in link once your payment confirms.",
      });
    }
    try {
      const url = `${KEYSAT_BASE_URL.replace(/\/$/, "")}/v1/purchase`;
      const r = await fetch(url, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          // Keysat's /v1/purchase expects `product` (NOT `product_slug`,
          // despite what its own developer-facing instructions show).
          // The SDK confirms this — see vendor/keysat-licensing-client
          // dist/index.js, startPurchase POST body.
          product: PRODUCT_SLUG,
          policy_slug: policySlug,
          buyer_email: buyerEmail || undefined,
          code: code || undefined,
          redirect_url: redirectUrl || undefined,
          buyer_note: buyerNote || undefined,
        }),
        signal: AbortSignal.timeout(15000),
      });
      const text = await r.text();
      let body = null;
      try { body = text ? JSON.parse(text) : null; } catch {}
      if (!r.ok) {
        return res.status(r.status === 400 ? 400 : 502).json({
          error:
            (body && (body.error || body.code)) || "purchase_failed",
          message:
            (body && (body.message || body.detail)) ||
            text?.slice(0, 300) ||
            `HTTP ${r.status}`,
        });
      }
      // Stash pending_signups row BEFORE responding so a browser
      // crash between buy + settle doesn't lose the buyer's email.
      // Keysat's response shape: invoice_id at the root.
      const invoiceId = body?.invoice_id || null;
      if (isAnonSignup && invoiceId) {
        try {
          const { getDb } = await import("./db.js");
          getDb()
            .prepare(
              `INSERT OR IGNORE INTO pending_signups
                 (invoice_id, email, policy_slug, created_at)
               VALUES (?, ?, ?, ?)`,
            )
            .run(
              invoiceId,
              buyerEmail.trim().toLowerCase(),
              policySlug,
              Date.now(),
            );
          console.log(
            `[license/purchase] tracked pending signup ${invoiceId} → ${buyerEmail} (${policySlug})`,
          );
        } catch (err) {
          // Non-fatal — the BTCPay invoice still goes through. If we
          // can't apply on settle, the operator can manually look up
          // the Keysat invoice + activate the license + email the
          // buyer.
          console.error(
            `[license/purchase] failed to record pending signup ${invoiceId}: ${err?.message || err}`,
          );
        }
      }
      // Pass the raw response straight through — the frontend reads
      // final_price_sats + discount_applied_sats off it to render the
      // discount preview before opening the checkout.
      res.json(body || {});
    } catch (err) {
      console.error(`[license/purchase] failed: ${err?.message || err}`);
      res.status(502).json({
        error: "purchase_failed",
        message: (err?.message || String(err)).slice(0, 300),
      });
    }
  });

  // ── Poll for license issuance ─────────────────────────────────────────────
  // While the BTCPay invoice is pending, status="pending" and no
  // licenseKey. Once paid + signed, status="settled" and the response
  // carries the LIC1-... key. On settle, we ACTIVATE the key on this
  // install (writes to /data/license.txt, refreshes module-scoped LIC
  // state) so the next license-status fetch reflects the new
  // entitlements. The frontend just calls /api/license-status after
  // this returns settled.
  app.get("/api/license/poll/:invoiceId", async (req, res) => {
    const invoiceId = (req.params.invoiceId || "").trim();
    if (!invoiceId) {
      return res.status(400).json({ error: "missing_invoice_id" });
    }
    try {
      const poll = await getClient().pollPurchase(invoiceId);
      if (
        poll.status === "settled" &&
        typeof poll.licenseKey === "string" &&
        poll.licenseKey.startsWith("LIC1-")
      ) {
        // Settle path forks on mode + buyer identity:
        //   - single mode: write the key to /data/license.txt so the
        //     OPERATOR install is now licensed (existing behavior).
        //   - multi mode + signed-in buyer: attach the key to the
        //     buyer's user row (users.keysat_license). The next
        //     resolveProviderOpts() call for that user picks up the
        //     new license automatically (req.user is re-read
        //     per-request, so the change is immediate after the
        //     next signed-in request hits the server).
        //   - multi mode + anon buyer with pending_signups row:
        //     create-or-match a user by buyer_email, attach the
        //     license, send a magic-link email so they can sign in.
        //     Idempotent via pending_signups.applied_at.
        //   - multi mode + anon buyer with NO pending row: fall back
        //     to the single-mode path (write to /data/license.txt).
        //     This is defensive — shouldn't happen in normal flow.
        try {
          let routedToUser = false;
          if (RECAP_MODE === "multi" && req?.user?.id) {
            const { getDb } = await import("./db.js");
            getDb()
              .prepare("UPDATE users SET keysat_license = ? WHERE id = ?")
              .run(poll.licenseKey, req.user.id);
            // Mutate the in-memory req.user so subsequent calls in
            // the same request see the new license. (next request
            // re-reads from DB so this is belt-and-suspenders.)
            req.user.keysat_license = poll.licenseKey;
            routedToUser = true;
            console.log(
              `[license/poll] attached license to user ${req.user.id}`,
            );
          } else if (RECAP_MODE === "multi") {
            const applied = await maybeApplyPendingSignup(
              invoiceId,
              poll.licenseKey,
              req,
            );
            if (applied) routedToUser = true;
          }
          if (!routedToUser) {
            license.activate(poll.licenseKey);
            if (typeof onLicenseActivated === "function") {
              await onLicenseActivated();
            }
          }
        } catch (e) {
          console.error(
            `[license/poll] activate-on-settle failed: ${e?.message || e}`
          );
          // Don't fail the response — the buyer's checkout succeeded,
          // they shouldn't see a 500. They can hit "I have a key" and
          // paste the key manually as a fallback (or, in multi mode,
          // operator can run an admin SQL update).
        }
      }
      res.json(poll);
    } catch (err) {
      console.error(`[license/poll] failed: ${err?.message || err}`);
      res.status(502).json({
        error: "poll_failed",
        message: (err?.message || String(err)).slice(0, 300),
      });
    }
  });
}

// maybeApplyPendingSignup(invoiceId, licenseKey, req) — multi-mode
// anon-signup settle path. Looks up the pending_signups row for this
// invoice and, if not already applied:
//   1. Create or match a user by buyer email (lowercased)
//   2. Attach the issued license to users.keysat_license
//   3. Send a "your Recap [Pro|Max] account is ready" magic-link email
//   4. Mark applied_at = now
// Returns true if applied, false if no pending row or already applied.
//
// Idempotent: a duplicate poll after settle is a no-op because
// applied_at is the guard.
async function maybeApplyPendingSignup(invoiceId, licenseKey, req) {
  const { getDb } = await import("./db.js");
  const db = getDb();
  const pending = db
    .prepare(
      `SELECT invoice_id, email, policy_slug, applied_at
       FROM pending_signups WHERE invoice_id = ?`,
    )
    .get(invoiceId);
  if (!pending) return false;
  if (pending.applied_at) return true; // already applied

  const email = pending.email;
  const policySlug = pending.policy_slug;

  // Parse the license to derive the tier label (for the email copy).
  // Falls back to "Pro" if parsing fails — same default as the UI.
  let tierLabel = "Pro";
  try {
    const { parseLicenseKey } = await import("./license.js");
    const parsed = parseLicenseKey(licenseKey);
    if (parsed && parsed.entitlements) {
      if (parsed.entitlements.has("max")) tierLabel = "Max";
      else if (parsed.entitlements.has("pro")) tierLabel = "Pro";
    }
  } catch {}

  // Create-or-match user. If a user with this email already exists,
  // attach the license to their account (case 'a' from the design
  // doc: "friendliest — handles the 'I forgot I had an account' case").
  // Otherwise insert a new stub user.
  let user = db
    .prepare("SELECT id, email, keysat_license FROM users WHERE email = ?")
    .get(email);

  const now = Date.now();
  const tx = db.transaction(() => {
    let userId;
    if (user) {
      userId = user.id;
      db.prepare(
        "UPDATE users SET keysat_license = ?, last_signin_at = ? WHERE id = ?",
      ).run(licenseKey, now, userId);
    } else {
      userId = randomUuid();
      const syntheticInstallId = randomUuid();
      db.prepare(
        `INSERT INTO users
          (id, email, created_at, last_signin_at, synthetic_install_id,
           keysat_license, is_admin)
         VALUES (?, ?, ?, ?, ?, ?, 0)`,
      ).run(userId, email, now, now, syntheticInstallId, licenseKey);
      console.log(
        `[license/poll] anon-signup created user ${userId} <${email}> with ${tierLabel} license`,
      );
    }
    db.prepare(
      "UPDATE pending_signups SET applied_at = ? WHERE invoice_id = ?",
    ).run(now, invoiceId);
    return userId;
  });
  const userId = tx();

  // Send the "your account is ready" magic-link email. Best-effort —
  // if SMTP fails, the user can hit /auth.html and request a regular
  // sign-in link (their account now exists with the license attached).
  try {
    const { sendSignInLink } = await import("./auth-routes.js");
    const { renderLicenseReadyEmail } = await import(
      "./license-ready-email.js"
    );
    // Build the email body using the verifyUrl that sendSignInLink
    // will produce. We pass emailBody to override the default sign-in
    // copy with the celebratory purchase-confirmation framing.
    // sendSignInLink generates verifyUrl internally and the body uses
    // a placeholder we string-substitute below — but actually
    // sendSignInLink already accepts emailBody. We need to construct
    // it with the verifyUrl already inserted. So we'll pre-render
    // emailBody with verifyUrl set to a token-placeholder string,
    // BUT sendSignInLink doesn't expose verifyUrl... Use a small
    // bridge: render emailBody after sendSignInLink runs by passing
    // a callback. Simplest: just send the link via auth-routes'
    // existing helper which has the right copy via emailBody override.
    //
    // Actually sendSignInLink computes verifyUrl after the token is
    // generated. To inject custom copy, the helper needs verifyUrl
    // in scope. We pass emailBody as a FUNCTION that receives the
    // verifyUrl and returns the {subject,text,html} tuple — but the
    // current signature accepts an object. Refactor in v0.2.94 if
    // needed. For now the helper accepts either object or function.
    await sendSignInLink({
      email,
      intent: "license_purchase",
      emailBody: (verifyUrl) =>
        renderLicenseReadyEmail({
          verifyUrl,
          tierLabel,
          brandName: "Recaps",
          expiresInMinutes: 15,
        }),
    });
  } catch (err) {
    console.error(
      `[license/poll] license-ready email send failed for ${email}: ${err?.message || err}`,
    );
  }
  return true;
}

// Local UUID helper — same shape we use in auth-routes for new users.
function randomUuid() {
  // Same crypto.randomBytes(16).toString("hex") pattern used elsewhere.
  return randomBytes(16).toString("hex");
}
