//! Public checkout flow, split across pages: the basket is `/cart`, then a
//! two-step wizard — `/checkout/info` (contact + addresses + company details)
//! followed by `/checkout/payment` (carrier + payment, which places the order)
//! — and finally the order confirmation page.
//!
//! The info step is persisted between pages in a short-lived, base64-encoded
//! JSON cookie (`checkout_info`); the payment step reads it back, places the
//! order, and clears both it and the cart cookie.

use axum::{extract::Query, response::Redirect};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::json;
use time::Duration as TimeDuration;
use crate::{
    controllers::cart::{self, resolve_cart},
    mailers::auth::AuthMailer,
    models::{
        customer_profiles::{self, ProfileFields},
        order_items, orders, payment_methods, shipping_methods,
        users::{self, normalize_account_type},
    },
    controllers::i18n::current_lang,
    shared::{currency::Currency, guard, money::format_price, settings, shipping as shipping_rules},
    views::checkout as view,
};

const INFO_COOKIE: &str = "checkout_info";
const QUOTE_COOKIE: &str = "checkout_quote";
const INFO_MAX_AGE_HOURS: i64 = 2;

/// The contact + address details captured on `/checkout/info`, carried to the
/// `/checkout/payment` step via the `checkout_info` cookie. `phone` is the local
/// number only; it is combined with `phone_prefix` when the order is placed.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CheckoutInfo {
    email: String,
    phone_prefix: String,
    phone: String,
    customer_name: String,
    account_type: String,
    company_name: Option<String>,
    company_id: Option<String>,
    tax_id: Option<String>,
    vat_id: Option<String>,
    residence_address: String,
    residence_city: String,
    residence_zip: String,
    residence_country: String,
    delivery_same: bool,
    address: String,
    city: String,
    zip: String,
    country: String,
}

/// A snapshot of the prices the buyer was shown on the payment page, stashed in
/// the `checkout_quote` cookie when that page renders. On submit the order total
/// is re-checked against this so a price (item *or* shipping) that changed in the
/// meantime can't be charged silently — the whole guard is server-side, with
/// nothing price-related trusted from the form. `shipping` maps carrier code to
/// the price shown for it.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CheckoutQuote {
    subtotal_cents: i64,
    shipping: std::collections::HashMap<String, i64>,
}

/// Step 1 form (`POST /checkout/info`).
#[derive(Debug, Deserialize)]
struct InfoForm {
    email: String,
    phone_prefix: String,
    phone: String,
    customer_name: String,
    account_type: Option<String>,
    company_name: Option<String>,
    company_id: Option<String>,
    tax_id: Option<String>,
    vat_id: Option<String>,
    residence_address: String,
    residence_city: String,
    residence_zip: String,
    residence_country: String,
    delivery_same_as_residence: Option<String>,
    address: Option<String>,
    city: Option<String>,
    zip: Option<String>,
    country: Option<String>,
}

/// Step 2 form (`POST /checkout/payment`).
#[derive(Debug, Deserialize)]
struct PaymentForm {
    note: Option<String>,
    payment_method: String,
    carrier_code: String,
    pickup_point_id: Option<String>,
    pickup_point_name: Option<String>,
    // Present (as "on") only when a logged-in customer ticks "save my address".
    save_profile: Option<String>,
    // Present only when a guest ticks "create an account from this order".
    create_account: Option<String>,
}

fn trimmed(value: &str) -> Option<String> {
    let value = value.trim();
    (!value.is_empty()).then(|| value.to_string())
}

/// A short-lived, base64-encoded JSON state cookie used to carry checkout state
/// (the info step, the price quote) between the wizard's pages.
fn state_cookie<T: Serialize>(name: &'static str, value: &T) -> Cookie<'static> {
    let encoded = URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).unwrap_or_default());
    Cookie::build((name, encoded))
        .path("/")
        .same_site(SameSite::Lax)
        .http_only(true)
        .max_age(TimeDuration::hours(INFO_MAX_AGE_HOURS))
        .build()
}

fn cleared_cookie(name: &'static str) -> Cookie<'static> {
    Cookie::build((name, ""))
        .path("/")
        .same_site(SameSite::Lax)
        .max_age(TimeDuration::seconds(0))
        .build()
}

fn decode_state<T: DeserializeOwned>(jar: &CookieJar, name: &str) -> Option<T> {
    let raw = jar.get(name)?;
    let bytes = URL_SAFE_NO_PAD.decode(raw.value()).ok()?;
    serde_json::from_slice(&bytes).ok()
}

async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
    shipping_rules::disable_packeta_if_unconfigured(ctx).await?;
    let packeta_ready = shipping_rules::packeta_ready(ctx);
    Ok(shipping_methods::Entity::find()
        .filter(shipping_methods::Column::Enabled.eq(true))
        .order_by_asc(shipping_methods::Column::Position)
        .all(&ctx.db)
        .await?
        .into_iter()
        .filter(|method| method.carrier != "packeta" || packeta_ready)
        .collect())
}

async fn enabled_payment_methods(ctx: &AppContext) -> Result<Vec<payment_methods::Model>> {
    Ok(payment_methods::Entity::enabled(&ctx.db).await?)
}

/// `/checkout` lands on the first wizard step.
#[debug_handler]
async fn checkout_redirect() -> Result<Response> {
    format::redirect("/checkout/info")
}

/// Step 1 page: contact, residence/delivery addresses and (for companies)
/// invoicing details. Prefilled from a returning customer's profile, or from
/// the `checkout_info` cookie when the buyer steps back from the payment page.
#[debug_handler]
async fn info_page(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    // Checkout and everything past it (orders, confirmation) stay in the EUR
    // base — the settlement currency — even when the buyer browsed in another.
    let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
    // No purchasable lines, or some line is out of stock: back to the basket to
    // resolve it before checking out.
    if cart.purchasable.is_empty() || cart.has_unavailable {
        return format::redirect("/cart");
    }
    let (lines, subtotal) = (cart.lines, cart.total);

    // Prefill the form for a logged-in customer: contact name/email come from
    // the user account, the address/phone from their saved profile (if any).
    let user = guard::current_user(&ctx, &jar).await;
    let is_admin = user.as_ref().is_some_and(|u| guard::is_admin(&ctx, u));
    let is_customer = user.is_some() && !is_admin;
    let profile = match (&user, is_customer) {
        (Some(u), true) => customer_profiles::Model::find_for_user(&ctx.db, u.id).await?,
        _ => None,
    };
    let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
        profile.as_ref().and_then(get)
    };

    // A previously entered info step (back navigation from the payment page)
    // takes precedence over the profile defaults.
    let saved = decode_state::<CheckoutInfo>(&jar, INFO_COOKIE);
    let s = |get: fn(&CheckoutInfo) -> String| saved.as_ref().map(get);
    let s_opt = |get: fn(&CheckoutInfo) -> Option<String>| saved.as_ref().and_then(get);

    let prefill_account_type = if is_customer {
        user.as_ref().map_or("personal", |u| u.account_type.as_str()).to_string()
    } else {
        saved.as_ref().map_or_else(|| "personal".to_string(), |s| s.account_type.clone())
    };

    format::view(
        &v,
        "shop/checkout_info.html",
        json!({
            "items": lines,
            "subtotal": format_price(subtotal),
            "subtotal_cents": subtotal,
            "logged_in_admin": is_admin,
            "logged_in_customer": is_customer,
            // Required by the navbar profile menu (base.html includes it whenever
            // logged_in_customer is true); None for admins/guests.
            "customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
            "customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
            "customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
            // A logged-in customer's account type is fixed; only guests pick it.
            "account_fixed": is_customer,
            "prefill_email": s(|x| x.email.clone()).or_else(|| user.as_ref().filter(|_| is_customer).map(|u| u.email.clone())),
            "prefill_name": s(|x| x.customer_name.clone()).or_else(|| user.as_ref().filter(|_| is_customer).map(|u| u.name.clone())),
            "prefill_account_type": prefill_account_type,
            "prefill_company_name": s_opt(|x| x.company_name.clone()).or_else(|| p(|x| x.company_name.clone())),
            "prefill_company_id": s_opt(|x| x.company_id.clone()).or_else(|| p(|x| x.company_id.clone())),
            "prefill_tax_id": s_opt(|x| x.tax_id.clone()).or_else(|| p(|x| x.tax_id.clone())),
            "prefill_vat_id": s_opt(|x| x.vat_id.clone()).or_else(|| p(|x| x.vat_id.clone())),
            "prefill_phone_prefix": s(|x| x.phone_prefix.clone()).or_else(|| p(|x| x.phone_prefix.clone())),
            "prefill_phone": s(|x| x.phone.clone()).or_else(|| p(|x| x.phone.clone())),
            "prefill_residence_address": s(|x| x.residence_address.clone()).or_else(|| p(|x| x.address.clone())),
            "prefill_residence_city": s(|x| x.residence_city.clone()).or_else(|| p(|x| x.city.clone())),
            "prefill_residence_zip": s(|x| x.residence_zip.clone()).or_else(|| p(|x| x.zip.clone())),
            "prefill_residence_country": s(|x| x.residence_country.clone()).or_else(|| p(|x| x.country.clone())),
            "prefill_delivery_same": saved.as_ref().is_some_and(|x| x.delivery_same),
            "prefill_delivery_address": s(|x| x.address.clone()),
            "prefill_delivery_city": s(|x| x.city.clone()),
            "prefill_delivery_zip": s(|x| x.zip.clone()),
            "prefill_delivery_country": s(|x| x.country.clone()),
            "lang": current_lang(&jar),
        }),
    )
}

/// Validate step 1, stash it in the `checkout_info` cookie and advance to the
/// payment step.
#[debug_handler]
async fn submit_info(
    jar: CookieJar,
    State(ctx): State<AppContext>,
    Form(form): Form<InfoForm>,
) -> Result<Response> {
    let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
    if cart.purchasable.is_empty() || cart.has_unavailable {
        return format::redirect("/cart");
    }

    let require = |value: &str, field: &str| -> Result<String> {
        trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required")))
    };
    let require_opt = |value: Option<&str>, field: &str| -> Result<String> {
        value
            .and_then(trimmed)
            .ok_or_else(|| Error::BadRequest(format!("{field} is required")))
    };

    let email = require(&form.email, "email")?;
    let number = require(&form.phone, "phone")?;
    let customer_name = require(&form.customer_name, "name")?;
    let residence_address = require(&form.residence_address, "residence address")?;
    let residence_city = require(&form.residence_city, "residence city")?;
    let residence_zip = require(&form.residence_zip, "residence zip")?;
    let residence_country = require(&form.residence_country, "residence country")?;

    let delivery_same = form.delivery_same_as_residence.is_some();
    let (address, city, zip, country) = if delivery_same {
        (
            residence_address.clone(),
            residence_city.clone(),
            residence_zip.clone(),
            residence_country.clone(),
        )
    } else {
        (
            require_opt(form.address.as_deref(), "delivery address")?,
            require_opt(form.city.as_deref(), "delivery city")?,
            require_opt(form.zip.as_deref(), "delivery zip")?,
            require_opt(form.country.as_deref(), "delivery country")?,
        )
    };

    // The account type is fixed for a logged-in customer (taken from their
    // account, never the form); a guest picks it on the form. Admins are guests.
    let current_user = guard::current_user(&ctx, &jar).await;
    let logged_in_customer = current_user.as_ref().filter(|u| !guard::is_admin(&ctx, u));
    let account_type = match logged_in_customer {
        Some(u) => u.account_type.clone(),
        None => normalize_account_type(form.account_type.as_deref()),
    };

    // Company purchases must carry the invoicing identifiers (IČO + DIČ
    // required, IČ DPH optional). Personal orders carry none.
    let (company_name, company_id, tax_id, vat_id) = if account_type == "company" {
        (
            Some(require(form.company_name.as_deref().unwrap_or(""), "company name")?),
            Some(require(form.company_id.as_deref().unwrap_or(""), "IČO")?),
            Some(require(form.tax_id.as_deref().unwrap_or(""), "DIČ")?),
            form.vat_id.as_deref().and_then(trimmed),
        )
    } else {
        (None, None, None, None)
    };

    let info = CheckoutInfo {
        email,
        phone_prefix: trimmed(&form.phone_prefix).unwrap_or_default(),
        phone: number,
        customer_name,
        account_type,
        company_name,
        company_id,
        tax_id,
        vat_id,
        residence_address,
        residence_city,
        residence_zip,
        residence_country,
        delivery_same,
        address,
        city,
        zip,
        country,
    };

    let jar = jar.add(state_cookie(INFO_COOKIE, &info));
    Ok((jar, Redirect::to("/checkout/payment")).into_response())
}

/// Step 2 page: carrier (with optional pickup point) and payment method, plus
/// the order summary. Requires the info step to have been completed.
#[debug_handler]
async fn payment_page(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Query(params): Query<std::collections::HashMap<String, String>>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
    if cart.purchasable.is_empty() || cart.has_unavailable {
        return format::redirect("/cart");
    }
    let (lines, subtotal) = (cart.lines, cart.total);
    if decode_state::<CheckoutInfo>(&jar, INFO_COOKIE).is_none() {
        return format::redirect("/checkout/info");
    }

    let shipping_models = enabled_shipping_methods(&ctx).await?;
    let methods: Vec<serde_json::Value> = shipping_models
        .iter()
        .map(|m| {
            json!({
                "code": m.code,
                "name": m.name,
                "price_cents": m.price_cents,
                "price": format_price(m.price_cents),
                "requires_pickup_point": m.requires_pickup_point,
            })
        })
        .collect();
    // Snapshot exactly what we're about to show the buyer (item subtotal + each
    // carrier's price) so the submit handler can detect a price change entirely
    // server-side, without trusting any figure from the form.
    let quote = CheckoutQuote {
        subtotal_cents: subtotal,
        shipping: shipping_models
            .iter()
            .map(|m| (m.code.clone(), m.price_cents))
            .collect(),
    };
    let payments: Vec<serde_json::Value> = enabled_payment_methods(&ctx)
        .await?
        .iter()
        .map(|m| {
            json!({
                "code": m.code,
                "label_key": m.label_key(),
            })
        })
        .collect();

    let user = guard::current_user(&ctx, &jar).await;
    let is_admin = user.as_ref().is_some_and(|u| guard::is_admin(&ctx, u));
    let is_customer = user.is_some() && !is_admin;
    let profile = match (&user, is_customer) {
        (Some(u), true) => customer_profiles::Model::find_for_user(&ctx.db, u.id).await?,
        _ => None,
    };
    // Whether the customer already has a residence address on file. When they do,
    // the "save this address to my profile" opt-in is pointless, so it's hidden.
    let profile_filled = profile
        .as_ref()
        .is_some_and(|pr| pr.address.is_some() && pr.city.is_some() && pr.zip.is_some());

    // The old/new order totals carried by the price-change bounce (display only;
    // the actual price is always re-verified server-side on submit).
    let price_old = params.get("old").and_then(|s| s.parse::<i64>().ok());
    let price_new = params.get("new").and_then(|s| s.parse::<i64>().ok());
    let price_delta = match (price_old, price_new) {
        (Some(o), Some(n)) => Some(n - o),
        _ => None,
    };

    let response = format::view(
        &v,
        "shop/checkout_payment.html",
        json!({
            "items": lines,
            "subtotal": format_price(subtotal),
            "subtotal_cents": subtotal,
            "shipping_methods": methods,
            "payment_methods": payments,
            "packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
            "logged_in_admin": is_admin,
            "logged_in_customer": is_customer,
            "customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
            "customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
            "customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
            "profile_filled": profile_filled,
            "can_create_account": user.is_none(),
            // Set when the buyer was bounced back because a price changed between
            // viewing this page and submitting; opens a dialog with the old/new
            // totals and the difference so they can accept or return to the basket.
            "price_changed": params.contains_key("price_changed"),
            "price_old": price_old.map(format_price),
            "price_new": price_new.map(format_price),
            "price_diff_abs": price_delta.map(|d| format_price(d.abs())),
            "price_increased": price_delta.map(|d| d > 0),
            "lang": current_lang(&jar),
        }),
    )?;
    // Carry the price snapshot to the submit step.
    let jar = jar.add(state_cookie(QUOTE_COOKIE, &quote));
    Ok((jar, response).into_response())
}

#[debug_handler]
async fn place_order(
    jar: CookieJar,
    State(ctx): State<AppContext>,
    Form(form): Form<PaymentForm>,
) -> Result<Response> {
    let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
    if cart.purchasable.is_empty() || cart.has_unavailable {
        return format::redirect("/cart");
    }
    let (valid, subtotal) = (cart.purchasable, cart.total);
    let Some(info) = decode_state::<CheckoutInfo>(&jar, INFO_COOKIE) else {
        return format::redirect("/checkout/info");
    };
    // The prices the buyer was shown on the payment page. Without it we can't
    // verify the total, so send them back to re-render (and re-quote).
    let Some(quote) = decode_state::<CheckoutQuote>(&jar, QUOTE_COOKIE) else {
        return format::redirect("/checkout/payment");
    };

    let email = info.email.clone();
    let customer_name = info.customer_name.clone();
    let number = info.phone.clone();
    // Combine the dialling-code prefix with the local number into one E.164-ish
    // value (e.g. "+421 900123456").
    let phone = if info.phone_prefix.is_empty() {
        number.clone()
    } else {
        format!("{} {}", info.phone_prefix, number)
    };

    // The account type is fixed for a logged-in customer (taken from their
    // account, never the cookie); a guest's choice rides in the info cookie.
    let current_user = guard::current_user(&ctx, &jar).await;
    let logged_in_customer = current_user.as_ref().filter(|u| !guard::is_admin(&ctx, u));
    let account_type = match logged_in_customer {
        Some(u) => u.account_type.clone(),
        None => info.account_type.clone(),
    };
    let (company_name, company_id, tax_id, vat_id) = if account_type == "company" {
        (
            info.company_name.clone(),
            info.company_id.clone(),
            info.tax_id.clone(),
            info.vat_id.clone(),
        )
    } else {
        (None, None, None, None)
    };

    if payment_methods::Entity::find_enabled(&ctx.db, &form.payment_method).await?.is_none() {
        return Err(Error::BadRequest("invalid payment method".to_string()));
    }

    // Resolve the chosen carrier from the enabled methods (price is taken from
    // the DB, never the form, so the customer can't pick their own fee).
    let method = shipping_methods::Entity::find()
        .filter(shipping_methods::Column::Code.eq(&form.carrier_code))
        .filter(shipping_methods::Column::Enabled.eq(true))
        .one(&ctx.db)
        .await?
        .ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?;
    if method.carrier == "packeta" && !shipping_rules::packeta_ready(&ctx) {
        return Err(Error::BadRequest("invalid shipping method".to_string()));
    }

    let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
        let id = form
            .pickup_point_id
            .as_deref()
            .and_then(trimmed)
            .ok_or_else(|| Error::BadRequest("a pickup point is required".to_string()))?;
        (Some(id), form.pickup_point_name.as_deref().and_then(trimmed))
    } else {
        (None, None)
    };

    // The total the buyer confirmed: the snapshotted item subtotal plus the price
    // shown for the carrier they chose. If that no longer matches what we'd charge
    // now (current subtotal + current carrier price), a price moved since they
    // looked — bounce back to re-confirm, before any side effects (account
    // creation, emails). `orders::place` re-checks the same figure against the
    // exact prices it charges (the authoritative guard); this just avoids the side
    // effects in the common case. Everything is server-side: nothing price-related
    // is trusted from the form.
    let expected_total = match quote.shipping.get(&form.carrier_code) {
        Some(shown) => quote.subtotal_cents + shown,
        None => return format::redirect("/checkout/payment?price_changed=1"),
    };
    let charge_total = subtotal + method.price_cents;
    if charge_total != expected_total {
        // Bounce back with the old/new totals so the payment page can show the
        // buyer a dialog with the difference and let them accept or return to the
        // basket.
        return format::redirect(&format!(
            "/checkout/payment?price_changed=1&old={expected_total}&new={charge_total}"
        ));
    }

    // The address/contact captured in the info step, ready to seed a profile (for
    // the logged-in "save my address" opt-in or a freshly created guest account).
    let entered_profile = || ProfileFields {
        company_name: company_name.clone(),
        company_id: company_id.clone(),
        tax_id: tax_id.clone(),
        vat_id: vat_id.clone(),
        phone_prefix: trimmed(&info.phone_prefix),
        phone: Some(number.clone()),
        address: Some(info.residence_address.clone()),
        city: Some(info.residence_city.clone()),
        zip: Some(info.residence_zip.clone()),
        country: Some(info.residence_country.clone()),
    };

    // Resolve the account that will own this order. A logged-in customer always
    // owns their orders. A guest may opt to create an account from the order;
    // the new account's type matches what they bought as, its profile is seeded
    // from the entered details, and a "set your password" link is emailed. If
    // the email already belongs to an account we silently fall back to a guest
    // order (no hijacking an existing account).
    let mut order_user_id = logged_in_customer.map(|u| u.id);
    let mut account_created = false;
    if order_user_id.is_none() && form.create_account.is_some() {
        match users::Model::create_guest_account(&ctx.db, &email, &customer_name, &account_type)
            .await
        {
            Ok(new_user) => {
                if let Err(err) =
                    customer_profiles::Model::upsert(&ctx.db, new_user.id, entered_profile()).await
                {
                    tracing::error!(error = %err, user_id = new_user.id, "failed to seed guest profile");
                }
                let user_id = new_user.id;
                match new_user.into_active_model().set_forgot_password_sent(&ctx.db).await {
                    Ok(user) => {
                        if let Err(err) = AuthMailer::send_set_password(&ctx, &user).await {
                            tracing::error!(error = %err, "failed to send set-password email");
                        }
                        order_user_id = Some(user_id);
                        account_created = true;
                    }
                    Err(err) => {
                        tracing::error!(error = %err, "failed to issue set-password token");
                        order_user_id = Some(user_id);
                    }
                }
            }
            Err(ModelError::EntityAlreadyExists {}) => {
                tracing::info!(email = %email, "checkout account-create skipped: email already registered");
            }
            Err(err) => tracing::error!(error = %err, "failed to create checkout account"),
        }
    }

    // If a logged-in customer opted in, persist this address to their profile so
    // the next checkout is prefilled. Best-effort: a failure here is logged but
    // must not block the order.
    if form.save_profile.is_some() {
        if let Some(user) = logged_in_customer {
            if let Err(err) =
                customer_profiles::Model::upsert(&ctx.db, user.id, entered_profile()).await
            {
                tracing::error!(error = %err, user_id = user.id, "failed to save checkout profile");
            }
        }
    }

    let order = orders::place(
        &ctx,
        &valid,
        orders::Checkout {
            email,
            phone,
            customer_name: Some(customer_name),
            user_id: order_user_id,
            account_type,
            company_name,
            company_id,
            tax_id,
            vat_id,
            residence_address: Some(info.residence_address.clone()),
            residence_city: Some(info.residence_city.clone()),
            residence_zip: Some(info.residence_zip.clone()),
            residence_country: Some(info.residence_country.clone()),
            address: Some(info.address.clone()),
            city: Some(info.city.clone()),
            zip: Some(info.zip.clone()),
            country: Some(info.country.clone()),
            note: form.note.as_deref().and_then(trimmed),
            payment_method: form.payment_method,
            method,
            pickup_point_id,
            pickup_point_name,
        },
        logged_in_customer,
        Some(expected_total),
    )
    .await?;

    // A price moved between the buyer confirming and the order being written:
    // nothing was charged or placed; send them back to re-confirm the new total.
    let Some(order) = order else {
        return format::redirect("/checkout/payment?price_changed=1");
    };

    let target = if account_created {
        format!("/orders/{}?account_created=1", order.order_number)
    } else {
        format!("/orders/{}", order.order_number)
    };
    if let Some(user) = logged_in_customer {
        cart::clear_account_cart(&ctx, user.id).await?;
    }
    format::render()
        .cookies(&[
            cart::cleared_cart_cookie(),
            cleared_cookie(INFO_COOKIE),
            cleared_cookie(QUOTE_COOKIE),
        ])?
        .redirect(&target)
}

#[debug_handler]
async fn order_confirmation(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Path(order_number): Path<String>,
    Query(params): Query<std::collections::HashMap<String, String>>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let order = orders::Entity::find()
        .filter(orders::Column::OrderNumber.eq(order_number))
        .one(&ctx.db)
        .await?
        .ok_or_else(|| Error::NotFound)?;
    let items = order_items::Entity::find()
        .filter(order_items::Column::OrderId.eq(order.id))
        .all(&ctx.db)
        .await?;
    let c = guard::chrome(&ctx, &jar).await;
    let account_created = params.contains_key("account_created");

    let (bank_iban, bank_account_name) = settings::bank_details(&ctx).await?;
    format::view(
        &v,
        "shop/order_confirmed.html",
        json!({
            "order": view::detail(
                &order,
                &bank_iban,
                &bank_account_name,
            ),
            "items": view::items(&items),
            "logged_in_admin": c.logged_in_admin,
            "logged_in_customer": c.logged_in_customer,
            "customer_name": c.customer_name,
            "customer_account_type": c.customer_account_type,
            "customer_avatar": c.customer_avatar,
            "account_created": account_created,
            "lang": current_lang(&jar),
        }),
    )
}

pub fn routes() -> Routes {
    Routes::new()
        .add("/checkout", get(checkout_redirect))
        .add("/checkout/info", get(info_page))
        .add("/checkout/info", post(submit_info))
        .add("/checkout/payment", get(payment_page))
        .add("/checkout/payment", post(place_order))
        .add("/orders/{order_number}", get(order_confirmation))
}
