//! Purchase flow:
//!   1. Client POSTs `/v1/purchase` with a product slug.
//!   2. We create a BTCPay invoice, stash a row, return the checkout URL.
//!   3. Client opens the URL, pays. BTCPay hits our webhook (see
//!      [`crate::api::webhook`]) which marks the invoice 'settled' and
//!      issues a license.
//!   4. Client polls `/v1/purchase/:invoice_id` until `license_key` is
//!      present, then stores it locally.

use crate::api::AppState;
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
use crate::db::repo;
use crate::error::{AppError, AppResult};
use crate::payment::{CreateInvoiceParams, Money};
use axum::{
    extract::{Path, State},
    Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Debug, Deserialize)]
pub struct StartPurchaseReq {
    /// Product slug to buy.
    pub product: String,
    /// Optional email for receipt / future contact.
    pub buyer_email: Option<String>,
    /// Optional free-text note from the buyer.
    pub buyer_note: Option<String>,
    /// Optional URL the buyer should be returned to after payment.
    pub redirect_url: Option<String>,
    /// Optional discount / referral code (case-insensitive).
    pub code: Option<String>,
    /// Optional tier (policy slug). When set, the policy's
    /// `price_sats_override` becomes the base price (if defined), and the
    /// chosen policy is remembered on the invoice so it's used at license
    /// issuance time. When omitted, the daemon falls back to the product's
    /// default policy at issuance — same as pre-:27 behaviour.
    pub policy_slug: Option<String>,
    /// Optional payment rail the buyer picked on the buy page. One of
    /// `lightning` / `onchain` / `card`. When omitted, the daemon picks
    /// the first rail the product's merchant profile exposes — which is
    /// the right behavior for single-rail profiles AND back-compat for
    /// pre-:52 callers that don't know about rails yet. When the buyer
    /// is on a multi-rail profile and the buy page surfaces a picker,
    /// this field carries the choice.
    pub rail: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct StartPurchaseResp {
    pub invoice_id: String,           // our internal id
    /// Empty for the free-tier shortcut path (price = 0 after override/discount):
    /// we synthesize a settled invoice locally and skip BTCPay entirely.
    pub btcpay_invoice_id: String,
    /// Non-empty on the paid path. On the free path, empty — the buyer should
    /// be shown the license card directly using `license_key` below.
    pub checkout_url: String,
    pub amount_sats: i64,             // what BTCPay was charged (post-discount)
    pub base_price_sats: i64,         // product list price (pre-discount)
    pub discount_applied_sats: i64,   // base - amount_sats; 0 if no code
    pub poll_url: String,             // where to check status
    /// Set when the daemon issued the license inline (free tier or 100%-off).
    /// When present, the client should display the license card directly
    /// instead of redirecting to a BTCPay checkout.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub license_key: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub license_id: Option<String>,
}

/// Floor for invoiced amount after a discount is applied. Set to 1 sat so
/// 100%-off codes still produce a real BTCPay invoice (and the buyer
/// experiences the purchase flow). 0-sat invoices aren't always supported
/// by BTCPay anyway.
const MIN_INVOICE_SATS: i64 = 1;

pub async fn start(
    State(state): State<AppState>,
    Json(req): Json<StartPurchaseReq>,
) -> AppResult<Json<StartPurchaseResp>> {
    let product = repo::get_product_by_slug(&state.db, &req.product)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product)))?;
    if !product.active {
        return Err(AppError::BadRequest(format!(
            "product '{}' is not available for purchase",
            req.product
        )));
    }

    // Resolve the optional tier (policy_slug). The chosen policy must be
    // active and public for it to be selectable from the public buy page.
    // (The admin can still issue under non-public policies via /v1/admin/licenses.)
    let chosen_policy = if let Some(ps) = req.policy_slug.as_deref().filter(|s| !s.is_empty()) {
        let pol = repo::get_policy_by_slug(&state.db, &product.id, ps)
            .await?
            .ok_or_else(|| {
                AppError::NotFound(format!(
                    "policy '{ps}' for product '{}'",
                    req.product
                ))
            })?;
        if !pol.active {
            return Err(AppError::BadRequest(format!(
                "policy '{ps}' is not active"
            )));
        }
        if !pol.public {
            return Err(AppError::BadRequest(format!(
                "policy '{ps}' is not available on the public buy page"
            )));
        }
        Some(pol)
    } else {
        None
    };

    // Effective base price in sats. For SAT-priced products this is
    // straightforward (policy override or product.price_sats). For
    // fiat-priced products (USD, EUR), we convert the listed value
    // to sats here using the daemon's rate fetcher — the rate gets
    // recorded on the invoice row below for audit. The CONTRACT to
    // the buyer is sat-denominated either way; the listed currency
    // is just the operator's display preference.
    //
    // We capture the listed (currency, value) and the rate-source
    // tuple so the invoice row carries the full audit trail.
    let mut listed_currency: Option<String> = None;
    let mut listed_value: Option<i64> = None;
    let mut exchange_rate_centibps: Option<i64> = None;
    let mut exchange_rate_source: Option<String> = None;

    let base_price: i64 = if product.price_currency == "SAT" {
        chosen_policy
            .as_ref()
            .and_then(|p| p.price_sats_override)
            .unwrap_or(product.price_sats)
    } else {
        // Fiat-priced. Use the policy override (in the same currency
        // as the product) if set, otherwise the product's listed
        // value. v0.3 will introduce per-policy currency overrides;
        // for now policies inherit the product's currency.
        let listed = chosen_policy
            .as_ref()
            .and_then(|p| p.price_sats_override) // legacy column; may carry override in fiat units after admin UI lands
            .unwrap_or(product.price_value);
        let conversion =
            crate::rates::convert_to_sats(&state, &product.price_currency, listed)
                .await
                .map_err(|e| AppError::Upstream(format!("rate fetch failed: {e:#}")))?;
        listed_currency = Some(product.price_currency.clone());
        listed_value = Some(listed);
        exchange_rate_centibps = conversion.rate_centibps;
        exchange_rate_source = Some(conversion.source);
        conversion.sats
    };

    // ----- Free-trial shortcut (recurring + trial_days > 0) -----
    // Before any pricing / discount logic: if the chosen policy is a
    // recurring subscription with trial_days > 0, the buyer pays
    // nothing today. We synthesize a settled free invoice, issue the
    // license inline with expires_at = now + trial_days, and create
    // the subscription row with next_renewal_at = trial_end so the
    // renewal worker fires the FIRST paid invoice when the trial
    // ends. Discount codes are deliberately ignored for trials —
    // they're already free; layering a discount on a free first
    // cycle is a no-op that just complicates the audit trail.
    if let Some(p) = chosen_policy.as_ref() {
        if p.is_recurring && p.trial_days > 0 {
            let free_invoice = repo::create_free_invoice(
                &state.db,
                &product.id,
                req.buyer_email.as_deref(),
                req.buyer_note.as_deref(),
                Some(p.id.as_str()),
            )
            .await?;

            // issue_license_for_invoice handles the recurring branch
            // (creates the subscription with next_renewal_at = trial_end)
            // because we now special-case is_recurring + trial_days
            // inside that function.
            let license_id = crate::api::webhook::issue_license_for_invoice(
                &state, &free_invoice,
            )
            .await?;

            // Re-derive the signed key.
            let lic = repo::get_license_by_invoice(&state.db, &free_invoice.id)
                .await?
                .ok_or_else(|| {
                    AppError::Internal(anyhow::anyhow!("license vanished after issue"))
                })?;
            let flags = if lic.is_trial { FLAG_TRIAL } else { 0 };
            let expires_at_unix = lic
                .expires_at
                .as_deref()
                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
                .map(|t| t.timestamp())
                .unwrap_or(0);
            let payload = LicensePayload {
                version: KEY_VERSION_V2,
                flags,
                product_id: uuid::Uuid::parse_str(&lic.product_id)
                    .map_err(|e| AppError::Internal(anyhow::anyhow!("bad product_id: {e}")))?,
                license_id: uuid::Uuid::parse_str(&lic.id)
                    .map_err(|e| AppError::Internal(anyhow::anyhow!("bad license_id: {e}")))?,
                issued_at: chrono::DateTime::parse_from_rfc3339(&lic.issued_at)
                    .map(|t| t.timestamp())
                    .unwrap_or(0),
                expires_at: expires_at_unix,
                fingerprint_hash: [0u8; 32],
                entitlements: lic.entitlements.clone(),
            };
            let sig = sign_payload(&state.keypair.signing, &payload);
            let license_key = encode_key(&payload, &sig);

            let poll_url = format!(
                "{}/v1/purchase/{}",
                state.config.public_base_url, free_invoice.id
            );

            tracing::info!(
                product_slug = %req.product,
                policy_slug = %p.slug,
                trial_days = p.trial_days,
                license_id = %license_id,
                "trial license issued — no charge for first cycle"
            );

            return Ok(Json(StartPurchaseResp {
                invoice_id: free_invoice.id.clone(),
                btcpay_invoice_id: free_invoice.btcpay_invoice_id.clone(),
                checkout_url: String::new(),
                amount_sats: 0,
                base_price_sats: base_price,
                discount_applied_sats: 0,
                poll_url,
                license_key: Some(license_key),
                license_id: Some(license_id),
            }));
        }
    }

    // Resolve and validate the discount code if one was supplied. The
    // ordering here matters: we must atomically reserve a counter slot
    // BEFORE we create the BTCPay invoice, so that a code-cap race can't
    // result in a buyer holding a discounted live invoice for an
    // already-exhausted code.
    //
    //   step A: lookup + eligibility checks (active, expired, applies-to)
    //   step B: atomically increment used_count (try_reserve_code_slot)
    //   step C: compute discount, create BTCPay invoice
    //   step D: persist local invoice
    //   step E: insert the pending redemption row (record_pending_redemption)
    //
    // If C, D, or E fail after B succeeded, we call release_code_slot to
    // give the slot back.
    // Determine the effective discount code. If the buyer typed one,
    // honor it (operator-typed beats auto-applied). Otherwise, look up
    // the most applicable active featured discount for the chosen
    // policy and use it. This is the "launch special" auto-apply
    // path — operators can run a public promo without making buyers
    // type the code.
    let explicit_code: Option<String> = req
        .code
        .as_deref()
        .filter(|s| !s.trim().is_empty())
        .map(|s| s.to_string());
    let effective_code: Option<String> = if explicit_code.is_some() {
        explicit_code
    } else if let Some(pol) = chosen_policy.as_ref() {
        repo::find_applicable_featured_discount(&state.db, &product.id, &pol.id)
            .await?
            .map(|c| c.code)
    } else {
        None
    };
    let (final_price, reservation, discount_applied) = if let Some(raw_code) =
        effective_code.as_deref().filter(|s| !s.trim().is_empty())
    {
        let code = repo::get_discount_code_by_code(&state.db, raw_code)
            .await?
            .ok_or_else(|| AppError::BadRequest("unknown discount code".into()))?;
        if !code.active {
            return Err(AppError::BadRequest("discount code is disabled".into()));
        }
        if let Some(exp) = &code.expires_at {
            if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
                if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
                    return Err(AppError::BadRequest("discount code has expired".into()));
                }
            }
        }
        if let Some(pid) = &code.applies_to_product_id {
            if pid != &product.id {
                return Err(AppError::BadRequest(
                    "discount code does not apply to this product".into(),
                ));
            }
        }
        // If the code is restricted to one or more policies and a tier
        // was selected, the chosen tier must be in the allowed set.
        // `allowed_policy_ids()` unifies the multi-policy column (0018)
        // and the legacy singular column. If no tier was selected, the
        // code is implicitly applied to the product's default policy at
        // issuance time, which we accept here (v0.1.0:27+).
        let allowed = code.allowed_policy_ids();
        if !allowed.is_empty() {
            if let Some(chosen) = &chosen_policy {
                if !allowed.iter().any(|p| *p == chosen.id) {
                    return Err(AppError::BadRequest(
                        "discount code does not apply to the selected tier".into(),
                    ));
                }
            }
        }

        // Step B: atomic reserve.
        repo::try_reserve_code_slot(&state.db, &code.id).await?;

        let discount = compute_discount(&code.kind, code.amount, base_price);
        let final_price = (base_price - discount).max(MIN_INVOICE_SATS);
        (final_price, Some(code), discount)
    } else {
        (base_price, None, 0)
    };

    // ----- Free-tier shortcut -----
    // If the post-discount, post-policy-override price came out at 0 sats
    // (price_sats_override = 0 on a "free" tier, OR a 100%-off discount on
    // a paid tier), skip BTCPay entirely. BTCPay refuses 0-sat invoices and
    // would also waste a UI step that prompts the buyer to "pay" zero. We
    // synthesize a settled invoice locally, issue the license inline, and
    // return the signed key in the response. The buy page renders the
    // license card directly.
    if final_price <= 0 {
        let free_invoice = repo::create_free_invoice(
            &state.db,
            &product.id,
            req.buyer_email.as_deref(),
            req.buyer_note.as_deref(),
            chosen_policy.as_ref().map(|p| p.id.as_str()),
        )
        .await
        .map_err(|e| {
            // If we got a code reservation earlier, release it.
            let pool = state.db.clone();
            let code = reservation.clone();
            tokio::spawn(async move {
                if let Some(c) = code {
                    let _ = repo::release_code_slot(&pool, &c.id).await;
                }
            });
            e
        })?;

        // If a discount code was applied, record the redemption.
        if let Some(code) = &reservation {
            let _ = repo::record_pending_redemption(
                &state.db,
                &code.id,
                &free_invoice.id,
                discount_applied,
                base_price,
                0,
            )
            .await;
        }

        // Issue the license. This finalizes the redemption row and fires
        // license.issued + (if applicable) code.redeemed webhooks.
        let license_id =
            crate::api::webhook::issue_license_for_invoice(&state, &free_invoice).await?;

        // Re-derive the signed key (same pattern as redeem.rs / status()).
        let lic = repo::get_license_by_invoice(&state.db, &free_invoice.id)
            .await?
            .ok_or_else(|| {
                AppError::Internal(anyhow::anyhow!("license vanished after issue"))
            })?;
        let flags = if lic.is_trial { FLAG_TRIAL } else { 0 };
        let expires_at_unix = lic
            .expires_at
            .as_deref()
            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
            .map(|t| t.timestamp())
            .unwrap_or(0);
        let payload = LicensePayload {
            version: KEY_VERSION_V2,
            flags,
            product_id: uuid::Uuid::parse_str(&lic.product_id)
                .map_err(|e| AppError::Internal(anyhow::anyhow!("bad product_id: {e}")))?,
            license_id: uuid::Uuid::parse_str(&lic.id)
                .map_err(|e| AppError::Internal(anyhow::anyhow!("bad license_id: {e}")))?,
            issued_at: chrono::DateTime::parse_from_rfc3339(&lic.issued_at)
                .map(|t| t.timestamp())
                .unwrap_or(0),
            expires_at: expires_at_unix,
            fingerprint_hash: [0u8; 32],
            entitlements: lic.entitlements.clone(),
        };
        let sig = sign_payload(&state.keypair.signing, &payload);
        let license_key = encode_key(&payload, &sig);

        let poll_url = format!(
            "{}/v1/purchase/{}",
            state.config.public_base_url, free_invoice.id
        );

        return Ok(Json(StartPurchaseResp {
            invoice_id: free_invoice.id.clone(),
            btcpay_invoice_id: free_invoice.btcpay_invoice_id.clone(), // "free-<uuid>"
            checkout_url: String::new(),                                // signal: no BTCPay
            amount_sats: 0,
            base_price_sats: base_price,
            discount_applied_sats: discount_applied,
            poll_url,
            license_key: Some(license_key),
            license_id: Some(license_id),
        }));
    }

    // Pre-allocate an internal invoice id so we can pass it to BTCPay as
    // metadata, letting us correlate webhook events back to our row even
    // before we've persisted the BTCPay invoice id.
    let internal_id = uuid::Uuid::new_v4().to_string();

    // Step B.5: resolve the merchant profile + payment provider for THIS
    // purchase. The product is attached to exactly one merchant profile;
    // the profile exposes one or more payment providers (BTCPay / Zaprite).
    // The buyer (or their UA) names a rail via `req.rail` if the buy page's
    // multi-rail picker surfaced one — otherwise we pick the first rail the
    // profile exposes, which is the right behavior for the common
    // single-rail-per-profile case. The resolution layer also returns the
    // provider row so we can record its id on the invoice; the renewal
    // worker reads that id off the snapshot when auto-charging future cycles.
    let merchant_profile = match state.merchant_profile_for_product(&product.id).await {
        Ok(p) => p,
        Err(e) => {
            if let Some(code) = &reservation {
                let _ = repo::release_code_slot(&state.db, &code.id).await;
            }
            return Err(e);
        }
    };
    let requested_rail = req
        .rail
        .as_deref()
        .and_then(crate::payment::Rail::parse);
    let rail = match requested_rail {
        Some(r) => r,
        None => {
            // No buyer pick — collect the union of rails this profile's
            // providers offer and use the first. With one provider this
            // is its primary rail; with multiple, this is whatever the
            // earliest-connected provider serves first.
            let providers = repo::list_payment_providers_for_profile(
                &state.db, &merchant_profile.id,
            )
            .await?;
            let first_rail = providers.iter().find_map(|row| {
                crate::payment::ProviderKind::parse(&row.kind)
                    .and_then(|kind| crate::payment::rails_for_kind(kind).into_iter().next())
            });
            match first_rail {
                Some(r) => r,
                None => {
                    if let Some(code) = &reservation {
                        let _ = repo::release_code_slot(&state.db, &code.id).await;
                    }
                    return Err(AppError::BadRequest(format!(
                        "merchant profile '{}' has no payment providers connected — \
                         buyers can't pay yet. Connect one in the admin UI.",
                        merchant_profile.name
                    )));
                }
            }
        }
    };
    let (provider_row, provider) = match state
        .resolve_provider_for_profile_rail(&merchant_profile.id, rail)
        .await
    {
        Ok(t) => t,
        Err(e) => {
            if let Some(code) = &reservation {
                let _ = repo::release_code_slot(&state.db, &code.id).await;
            }
            return Err(e);
        }
    };

    // If the caller didn't supply a redirect_url, prefer the merchant
    // profile's configured post_purchase_redirect_url (operator's app
    // landing page — e.g. recaps.cc/welcome). Fall back to Keysat's own
    // /thank-you?invoice_id=… page if neither is set.
    let default_redirect = format!(
        "{}/thank-you?invoice_id={}",
        state.config.public_base_url, internal_id
    );
    let profile_redirect = merchant_profile
        .post_purchase_redirect_url
        .as_deref()
        .filter(|s| !s.is_empty())
        .map(|tmpl| {
            // Allow `{invoice_id}` substitution so operators can land
            // buyers on a per-purchase URL on their own app.
            tmpl.replace("{invoice_id}", &internal_id)
        });
    let profile_redirect_ref = profile_redirect.as_deref();
    let redirect_url = req
        .redirect_url
        .as_deref()
        .filter(|s| !s.is_empty())
        .or(profile_redirect_ref)
        .unwrap_or(&default_redirect);
    // Recurring policy: ask the provider to prompt the buyer to
    // save their payment profile at checkout so the renewal worker
    // can later auto-charge it via `charge_order_with_profile`.
    // Zaprite honors this for autopay-supporting rails (Stripe card
    // via a connected merchant account); BTCPay has no equivalent
    // and silently ignores the flag. We always set this on
    // recurring purchases — if the buyer ends up paying with
    // Bitcoin / Lightning, or declines the save-card prompt at
    // Zaprite's checkout, no profile gets created and the post-
    // settle profile-capture step finds nothing. The sub then
    // behaves like a pre-feature recurring sub: renewals still
    // create fresh invoices the buyer pays manually.
    let allow_save_profile =
        chosen_policy.as_ref().map(|p| p.is_recurring).unwrap_or(false);
    let created = match provider
        .create_invoice(CreateInvoiceParams {
            amount: Money::sats(final_price),
            redirect_url,
            // We pass `productId` through for any provider that exposes
            // it on its dashboard / receipt. The trait's enrichment
            // adds `orderId` (= our internal_id) and `source` so
            // webhooks can be correlated to the local invoice row.
            metadata: json!({ "productId": product.id }),
            external_order_id: &internal_id,
            buyer_email: req.buyer_email.as_deref(),
            allow_save_payment_profile: if allow_save_profile { Some(true) } else { None },
        })
        .await
    {
        Ok(handle) => handle,
        Err(e) => {
            if let Some(code) = &reservation {
                let _ = repo::release_code_slot(&state.db, &code.id).await;
            }
            // `{e:#}` (alternate format) walks the anyhow error chain so
            // the buy page surfaces the underlying provider error directly
            // — e.g. "Zaprite create_order returned HTTP 400: {...}" —
            // instead of just the outermost `context()` wrapper. Without
            // this, a failed create-invoice shows only
            // "ZapriteProvider.create_invoice" to the operator, and the
            // real cause (currency mismatch / missing payment rail / API-
            // key scope / Zaprite-side validation error) is hidden. We
            // ALSO emit an explicit tracing::error! before returning so
            // the same chain shows up in the daemon logs — without this
            // line, the provider's underlying error string is never
            // logged anywhere (the trait method just RETURNS the
            // anyhow error; only the tower trace layer fires, and it
            // only sees the HTTP status code, not the body).
            tracing::error!(
                product_id = %product.id,
                error = format!("{e:#}"),
                "payment provider create_invoice failed"
            );
            return Err(AppError::Upstream(format!(
                "payment provider create-invoice failed: {e:#}"
            )));
        }
    };
    let checkout_url = created.checkout_url.clone();

    // Step D: persist local invoice. On failure, release the slot.
    // Use internal_id we pre-generated (and baked into the BTCPay
    // redirect_url) as the local row id so /v1/purchase/<id> and
    // /thank-you?invoice_id=<id> all resolve to the same row.
    let invoice = match repo::create_invoice_with_currency(
        &state.db,
        &internal_id,
        &created.provider_invoice_id,
        &product.id,
        final_price,
        &checkout_url,
        req.buyer_email.as_deref(),
        req.buyer_note.as_deref(),
        chosen_policy.as_ref().map(|p| p.id.as_str()),
        listed_currency.as_deref(),
        listed_value,
        exchange_rate_centibps,
        exchange_rate_source.as_deref(),
        Some(&provider_row.id),
    )
    .await
    {
        Ok(inv) => inv,
        Err(e) => {
            if let Some(code) = &reservation {
                let _ = repo::release_code_slot(&state.db, &code.id).await;
            }
            return Err(e);
        }
    };

    // Step E: persist the redemption row tying the slot to the invoice.
    if let Some(code) = &reservation {
        if let Err(e) = repo::record_pending_redemption(
            &state.db,
            &code.id,
            &invoice.id,
            discount_applied,
            base_price,
            final_price,
        )
        .await
        {
            // Slot was reserved but we couldn't record the redemption.
            // Release the slot and mark the BTCPay invoice as invalid
            // locally so we don't accidentally honour it on settle.
            tracing::error!(
                code = %code.code,
                invoice_id = %invoice.id,
                error = %e,
                "failed to persist pending redemption; releasing slot \
                 and invalidating local invoice"
            );
            let _ = repo::release_code_slot(&state.db, &code.id).await;
            let _ = repo::update_invoice_status(&state.db, &created.provider_invoice_id, "invalid").await;
            return Err(e);
        }
    }

    let poll_url = format!("{}/v1/purchase/{}", state.config.public_base_url, invoice.id);

    Ok(Json(StartPurchaseResp {
        invoice_id: invoice.id,
        btcpay_invoice_id: created.provider_invoice_id,
        checkout_url,
        amount_sats: final_price,
        base_price_sats: base_price,
        discount_applied_sats: discount_applied,
        poll_url,
        license_key: None,
        license_id: None,
    }))
}

/// Apply the discount math. Returns the sats to subtract from `base`.
/// Caller is responsible for clamping the result (and for floor enforcement).
/// `pub(crate)` so the public policies endpoint can preview the post-
/// discount price on tier cards for featured (auto-applied) codes.
pub(crate) fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 {
    match kind {
        "percent" => {
            // amount is basis points (0..=10000). 5000 == 50%.
            // Multiply in i128 to avoid overflow on large sat amounts.
            let bps = amount.clamp(0, 10_000) as i128;
            let base = base_price_sats as i128;
            ((base * bps) / 10_000).max(0).min(base) as i64
        }
        "fixed_sats" => amount.max(0).min(base_price_sats),
        // 'set_price' = the buyer pays exactly `amount` sats regardless of
        // base price. Compute it as a discount: subtract enough to land at
        // `amount`. If `amount >= base_price_sats`, the code provides no
        // benefit (discount = 0).
        "set_price" => {
            let target = amount.max(0);
            if target >= base_price_sats {
                0
            } else {
                base_price_sats - target
            }
        }
        _ => 0,
    }
}

/// Polling endpoint — returns status; if settled and a license has been
/// issued, returns the signed key string.
pub async fn status(
    State(state): State<AppState>,
    Path(invoice_id): Path<String>,
) -> AppResult<Json<Value>> {
    let invoice = repo::get_invoice_by_id(&state.db, &invoice_id)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("invoice '{invoice_id}'")))?;

    let license = repo::get_license_by_invoice(&state.db, &invoice.id).await?;

    let license_key = match &license {
        Some(lic) if lic.status == "active" => {
            // Re-issue the encoded key deterministically from the stored
            // license row. `issued_at` is parseable as RFC3339; we reduce to
            // unix seconds. Fingerprint binding isn't done here because the
            // key is still unbound at first delivery — it'll be bound the
            // first time the app calls /v1/validate or /v1/machines/activate.
            let flags = if lic.is_trial { FLAG_TRIAL } else { 0 };
            let expires_at = lic
                .expires_at
                .as_deref()
                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
                .map(|t| t.timestamp())
                .unwrap_or(0);
            let payload = LicensePayload {
                version: KEY_VERSION_V2,
                flags,
                product_id: uuid::Uuid::parse_str(&lic.product_id).map_err(|e| {
                    AppError::Internal(anyhow::anyhow!("bad stored product_id: {e}"))
                })?,
                license_id: uuid::Uuid::parse_str(&lic.id).map_err(|e| {
                    AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}"))
                })?,
                issued_at: chrono::DateTime::parse_from_rfc3339(&lic.issued_at)
                    .map(|t| t.timestamp())
                    .unwrap_or(0),
                expires_at,
                fingerprint_hash: [0u8; 32],
                entitlements: lic.entitlements.clone(),
            };
            let sig = sign_payload(&state.keypair.signing, &payload);
            Some(encode_key(&payload, &sig))
        }
        _ => None,
    };

    Ok(Json(json!({
        "invoice_id": invoice.id,
        "status": invoice.status,
        "product_id": invoice.product_id,
        "amount_sats": invoice.amount_sats,
        "license_key": license_key,
        "license_id": license.as_ref().map(|l| l.id.clone()),
    })))
}
