Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-site-buy/src/testkit.rs

//! **A fake ONE box and a fake Stripe, for the buy flow's tests** (feature `testkit`).
//!
//! [`FakeOneBox`] serves the monetize services a site's relay calls (`Signup`, `EstateOrder`,
//! `Purchase`, `Products`, and `Entitlement` for a site that reads its held caps there) on a loopback port,
//! over monetize-proto, and keeps a [`Book`] of what it heard and decided. Its Stripe is a
//! ledger of checkouts: each minted checkout is one Stripe page, and [`FakeOneBox::pay`] is
//! the vendor's paid event (the account is made, the instance is queued, the room is granted).
//!
//! It stands in for the ONE box in a product's own tests too (gunnar-ui-server, holger's
//! front): a site mounts its real provider over the relay and points it here. Nothing in it
//! is a real price or a real address: every host is `*.mock.invalid`.

use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex, MutexGuard};

use monetize_proto as pb;
use tonic::{Code, Request, Response, Status};

use crate::link::{Door, ShopLink};

/// What the box answers a ticket it honoured before (monetize-server `site_ticket::SPENT`).
pub const SPENT: &str = "this ticket has already been spent; ask the appliance for another";
/// The fake vendor's checkout host.
pub const STRIPE: &str = "https://stripe.mock.invalid";
/// The apex the fake lays instances under.
pub const APEX: &str = "estates.mock.invalid";
/// The free account's plan row, and the paid one's, under the product (`<product>/<row>`).
pub const FREE_ROW: &str = "account";
pub const PAID_ROW: &str = "10gb-1m@11000SEK";
/// The paid plan's amount, minor units, and its words.
pub const PAID_MINOR: u64 = 11_000;
pub const PAID_WORDS: &str = "110.00 SEK a month";
/// The ladder: (name, main plan, data GiB, monthly words, minor per month).
pub const LADDER: &[(&str, &str, u64, &str, u64)] = &[("Mini", "m-mini", 40, "96.20 EUR a month", 9_620), ("Small", "m-small", 120, "132.00 EUR a month", 13_200)];
/// The term a private instance is paid for, months.
pub const TERM_MONTHS: u64 = 4;
/// The account's grow: one bundle.
pub const BUNDLE: &str = "more10";
pub const BUNDLE_BYTES: u64 = 10 << 30;
pub const BUNDLE_MINOR: u64 = 22_000;
pub const BUNDLE_WORDS: &str = "220.00 SEK";
/// What the free account and the paid plan hold.
pub const FREE_BYTES: u64 = 1 << 30;
pub const PAID_BYTES: u64 = 10 << 30;
/// ★ **What the ONE box says while a paid account settles** (PAID-WORDS, 2026-10-26): the
/// account is queued for the product's main and made on its next call, a few seconds. These are
/// monetize-server's own words for that state (`signup::customer_words`); its test
/// `the_fake_box_says_what_the_box_says_while_an_account_settles` holds the two to one sentence.
pub const SETTLING_WORDS: &str = "Payment received. Setting up your account; this takes a few seconds.";
/// ★ RA4-19: **the sentence the box sends AHEAD with a settling answer**, for its silence: what
/// the page shows once the box's settle bound has passed with no answer (monetize-server
/// `signup::words::TAKING_LONGER_MAILED`, the box mailing the sign-in link), due
/// [`SILENT_AFTER_MS`] after the answer.
pub const LONGER_WORDS: &str =
    "Payment received. This is taking longer than usual; your payment is safe, and we will e-mail you the sign-in link as soon as your account is ready — you do not need to pay again.";
/// When [`LONGER_WORDS`] is due after a settling answer: the box's settle bound (monetize-server
/// `signup::SETTLE_BOUND_MS`), from the payment; the fake's payment is "just now".
pub const SILENT_AFTER_MS: i64 = 2 * 60 * 1000;
/// What the box says while the PRODUCT has told it, by name, that it is in maintenance
/// (monetize-server `signup::words::MAINTENANCE`).
pub const MAINTENANCE_WORDS: &str =
    "Payment received. The shop is temporarily down for maintenance; your account will be ready as soon as it is back — you do not need to pay again.";

/// What a checkout is for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum What {
    /// A new account (the free account's 0 kr card check, or a paid plan).
    Signup { username: String, plan: String, email: String },
    /// A private instance; `new_account` when a visitor pays for the account with it.
    Estate { company: String, account: String, new_account: bool, email: String, step: String },
    /// A change of an instance's plan.
    Change { company: String, step: String },
    /// The account's grow.
    Bundle { tenant: String, bundle: String },
}

/// **One Stripe page.**
#[derive(Clone, Debug)]
pub struct Checkout {
    /// `cs_test_…`: the vendor's session id.
    pub id: String,
    pub what: What,
    pub amount_minor: u64,
    /// Stripe's 0 kr card check (mode `setup`), not a payment.
    pub setup: bool,
    /// Where the vendor sends the buyer when paid (`{session}` unreplaced), and on Cancel.
    pub success_url: String,
    pub cancel_url: String,
    /// The shop's reference (a purchase's `p-<n>`, an order's `estate/<co>`).
    pub reference: String,
    pub paid: bool,
}

impl Checkout {
    /// The page the buyer is sent to.
    pub fn url(&self) -> String {
        format!("{STRIPE}/c/pay/{}", self.id)
    }

    /// Where the vendor sends the buyer back after paying.
    pub fn return_url(&self) -> String {
        self.success_url.replace("{session}", &self.id)
    }
}

/// **A mail the shop sent.**
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mail {
    pub to: String,
    /// `sign-in` (a new account's link), `sign-in-again`.
    pub kind: String,
    pub link: String,
}

/// An account the shop made.
#[derive(Clone, Debug, Default)]
pub struct Acct {
    pub email: String,
    pub plan: String,
    pub caps: BTreeMap<String, u64>,
}

/// A private instance the shop sold.
#[derive(Clone, Debug, Default)]
pub struct Estate {
    pub account: String,
    pub step: String,
    /// `waiting_for_payment` → `queued` → `laying` → `ready`.
    pub state: String,
    /// A change asked and its state (`ordered`, `done`).
    pub change: Option<(String, String)>,
}

/// **What the fake heard and decided.**
#[derive(Default)]
pub struct Book {
    /// Every call, `Service.Method`, in order.
    pub calls: Vec<String>,
    /// The `authorization` every call came with.
    pub bearers: Vec<String>,
    pub checkouts: Vec<Checkout>,
    pub mails: Vec<Mail>,
    pub accounts: BTreeMap<String, Acct>,
    pub estates: BTreeMap<String, Estate>,
    /// Every e-mail address a call carried: `(call, address)`.
    pub emails: Vec<(String, String)>,
    /// Every way back a call carried: `(call, url)`.
    pub urls: Vec<(String, String)>,
    /// Every actor ticket a call carried: `(call, ticket)`.
    pub tickets: Vec<(String, Vec<u8>)>,
    /// Every call answers this (an ONE box that errs).
    pub fail: Option<(Code, String)>,
    /// The plans, the ladder and the sells word answer only after this (a slow or stuck ONE box).
    pub stall: Option<std::time::Duration>,
    /// The customer's sentence riding beside [`Self::fail`] (`monetize-customer-words-bin`).
    pub fail_words: Option<String>,
    /// Purchases by reference: (checkout index).
    purchases: BTreeMap<String, usize>,
    /// The purchases their buyer cancelled before paying ("Cancel order"), by reference.
    pub cancelled: std::collections::BTreeSet<String>,
    /// Whether this box spends each tenant ticket once ([`FakeOneBox::verify_tickets`]).
    pub verifies_tickets: bool,
    /// ★ A32-9: this box says it checks NO tenant ticket ([`FakeOneBox::checks_no_tickets`]): its
    /// `Products.List` answers `tenant_renewal = false`, a shop older than the ticket.
    pub checks_no_tickets: bool,
    /// The tickets a `Purchase` call honoured, once each (a box that verifies them).
    pub spent: std::collections::BTreeSet<Vec<u8>>,
    /// ★ How many `Signup.Status` polls a paid sign-up stays PENDING after paying, settling
    /// ([`FakeOneBox::settle_after`]); 0: made at once.
    pub settle_polls: u32,
    /// The settle's polls (1-based, counted after paying) the box does not answer
    /// ([`FakeOneBox::status_fails_at`]).
    pub status_fails: std::collections::BTreeSet<u32>,
    /// The product has told the box it is in maintenance ([`FakeOneBox::in_maintenance`]).
    pub maintenance: bool,
    /// The `Signup.Status` polls each paid checkout has had since paying, by checkout id.
    pub status_polls: BTreeMap<String, u32>,
    /// The account's grow window rows, as a test sets them ([`FakeOneBox::sell_bundles`]);
    /// `None`: the default two ([`BUNDLE`] and an unservable one).
    pub bundles: Option<Vec<pb::Bundle>>,
}

impl Book {
    /// The checkouts minted for a call kind (`signup`, `estate`, `change`, `bundle`).
    pub fn checkouts_of(&self, kind: &str) -> Vec<&Checkout> {
        self.checkouts
            .iter()
            .filter(|c| matches!((&c.what, kind), (What::Signup { .. }, "signup") | (What::Estate { .. }, "estate") | (What::Change { .. }, "change") | (What::Bundle { .. }, "bundle")))
            .collect()
    }

    pub fn checkout(&self, id_or_url: &str) -> Option<&Checkout> {
        let id = id_or_url.rsplit('/').next().unwrap_or(id_or_url);
        self.checkouts.iter().find(|c| c.id == id)
    }

    pub fn count(&self, call: &str) -> usize {
        self.calls.iter().filter(|c| *c == call).count()
    }
}

/// **The fake ONE box.** Clone it freely: every clone is the same box.
#[derive(Clone)]
pub struct FakeOneBox {
    book: Arc<Mutex<Book>>,
    pub product: String,
    pub bearer: String,
    pub addr: SocketAddr,
}

fn minor_words(minor: u64, cur: &str) -> String {
    format!("{}.{:02} {cur}", minor / 100, minor % 100)
}

impl FakeOneBox {
    /// **A fake ONE box for `product` on a loopback port**, insisting on the site's `bearer`.
    pub async fn spawn(product: &str, bearer: &str) -> FakeOneBox {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a loopback port");
        let addr = listener.local_addr().expect("its address");
        let fake = FakeOneBox { book: Arc::default(), product: product.to_owned(), bearer: bearer.to_owned(), addr };
        let serve = tonic::transport::Server::builder()
            .add_service(pb::signup_server::SignupServer::new(fake.clone()))
            .add_service(pb::estate_order_server::EstateOrderServer::new(fake.clone()))
            .add_service(pb::purchase_server::PurchaseServer::new(fake.clone()))
            .add_service(pb::products_server::ProductsServer::new(fake.clone()))
            .add_service(pb::entitlement_server::EntitlementServer::new(fake.clone()))
            .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener));
        tokio::spawn(serve);
        fake
    }

    /// `http://127.0.0.1:<port>`.
    pub fn url(&self) -> String {
        format!("http://{}", self.addr)
    }

    /// The site's link to this box, with the site's bearer.
    pub fn link(&self) -> ShopLink {
        ShopLink::connect_lazy(&Door { url: self.url(), ..Door::default() }, &self.bearer, &self.product).expect("a link to the fake")
    }

    /// What the box heard and decided.
    pub fn book(&self) -> MutexGuard<'_, Book> {
        self.book.lock().unwrap_or_else(|p| p.into_inner())
    }

    /// From now on every call answers `code` (`None`: answer again).
    /// From now on the plans, the ladder and the sells word answer only after `d` (`None`: at once).
    pub fn stall(&self, d: Option<std::time::Duration>) {
        self.book().stall = d;
    }

    async fn stalled(&self) {
        let d = self.book().stall;
        if let Some(d) = d {
            tokio::time::sleep(d).await;
        }
    }

    pub fn fail(&self, code: Option<(Code, &str)>) {
        let mut b = self.book();
        b.fail = code.map(|(c, m)| (c, m.to_owned()));
        b.fail_words = None;
    }

    /// From now on every call answers `code` with the operator's `message`, and the customer's
    /// `words` beside it, as monetize's quote refusals do.
    pub fn fail_saying(&self, code: Code, message: &str, words: &str) {
        let mut b = self.book();
        b.fail = Some((code, message.to_owned()));
        b.fail_words = Some(words.to_owned());
    }

    /// **From now on this box verifies the product's tenant tickets**, as a configured ONE box
    /// does (`Products.List` answers `tenant_renewal`; monetize-server `site_ticket`): every
    /// `Purchase` call honours a ticket ONCE, and the same ticket again is refused as spent
    /// ([`SPENT`]). A box left as spawned SAYS it checks them (`tenant_renewal`, as a configured
    /// ONE box), but spends none, so a test's plain tickets may be presented again.
    pub fn verify_tickets(&self) {
        self.book().verifies_tickets = true;
    }

    /// ★ **A paid account settles over `polls` status polls** (PAID-WORDS): after the vendor's
    /// paid event the box answers `Signup.Status` PENDING, in [`SETTLING_WORDS`], for the first
    /// `polls` polls (the account waits for the product's main, as a pulled product's does), and
    /// CREATED after.
    pub fn settle_after(&self, polls: u32) {
        self.book().settle_polls = polls;
    }

    /// The settle's polls `at` (1-based, after paying) answer UNAVAILABLE: a blip of the box
    /// while the account is being made.
    pub fn status_fails_at(&self, at: &[u32]) {
        self.book().status_fails = at.iter().copied().collect();
    }

    /// The product says, by name, that it is in maintenance: a settling account reads
    /// [`MAINTENANCE_WORDS`].
    pub fn in_maintenance(&self, on: bool) {
        self.book().maintenance = on;
    }

    /// ★ **A32-9: from now on this box says it checks no tenant ticket** (`Products.List`
    /// `tenant_renewal = false`: a shop older than the ticket). Every site's grow doors refuse
    /// there with the buyer's words; no `Purchase` call is made.
    pub fn checks_no_tickets(&self) {
        self.book().checks_no_tickets = true;
    }

    /// An account the shop knows already (a signed-in buyer's), holding `caps`.
    pub fn add_account(&self, name: &str, email: &str, caps: &[(&str, u64)]) {
        self.book().accounts.insert(name.to_owned(), Acct { email: email.to_owned(), plan: String::new(), caps: caps.iter().map(|(k, v)| ((*k).to_owned(), *v)).collect() });
    }

    /// **The grow window this box answers** (`Purchase.Bundles`), in the order given: a test
    /// that plays the ONE box's real list (monetize-server answers it cheapest first).
    pub fn sell_bundles(&self, rows: Vec<pb::Bundle>) {
        self.book().bundles = Some(rows);
    }

    /// An instance the account bought already, ready.
    pub fn add_estate(&self, company: &str, account: &str, step: &str) {
        self.book().estates.insert(company.to_owned(), Estate { account: account.to_owned(), step: step.to_owned(), state: "ready".into(), change: None });
    }

    /// **The vendor's paid event** for the checkout `id_or_url`: the shop settles it once (a
    /// second event changes nothing). `false`: no such checkout.
    pub fn pay(&self, id_or_url: &str) -> bool {
        let mut b = self.book();
        let id = id_or_url.rsplit('/').next().unwrap_or(id_or_url).to_owned();
        let Some(i) = b.checkouts.iter().position(|c| c.id == id) else { return false };
        if b.cancelled.contains(&b.checkouts[i].reference) {
            return false;
        }
        if b.checkouts[i].paid {
            return true;
        }
        b.checkouts[i].paid = true;
        let c = b.checkouts[i].clone();
        let back = c.return_url();
        match c.what {
            What::Signup { username, plan, email } => {
                let bytes = if c.setup { FREE_BYTES } else { PAID_BYTES };
                b.accounts.insert(username.clone(), Acct { email: email.clone(), plan, caps: BTreeMap::from([("disk_bytes".to_owned(), bytes)]) });
                b.mails.push(Mail { to: email, kind: "sign-in".into(), link: back.clone() });
            }
            What::Estate { company, account, new_account, email, step } => {
                if new_account {
                    b.accounts.insert(account.clone(), Acct { email: email.clone(), plan: String::new(), caps: BTreeMap::new() });
                    b.mails.push(Mail { to: email, kind: "sign-in".into(), link: back.clone() });
                }
                b.estates.insert(company, Estate { account, step, state: "queued".into(), change: None });
            }
            What::Change { company, step } => {
                if let Some(e) = b.estates.get_mut(&company) {
                    e.step = step.clone();
                    e.change = Some((step, "done".into()));
                }
            }
            What::Bundle { tenant, .. } => {
                if let Some(a) = b.accounts.get_mut(&tenant) {
                    let held = a.caps.entry("disk_bytes".into()).or_insert(0);
                    *held = held.saturating_add(BUNDLE_BYTES);
                }
            }
        }
        true
    }

    /// The box's verdict on a call: its bearer, its failure, and the call written down.
    fn hear<T>(&self, call: &str, r: &Request<T>) -> Result<(), Status> {
        let bearer = r.metadata().get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
        let mut b = self.book();
        b.calls.push(call.to_owned());
        b.bearers.push(bearer.clone());
        if let Some((code, message)) = &b.fail {
            let mut status = Status::new(*code, message.clone());
            if let Some(words) = &b.fail_words {
                status.metadata_mut().insert_bin(crate::rules::CUSTOMER_WORDS_KEY, tonic::metadata::MetadataValue::from_bytes(words.as_bytes()));
            }
            return Err(status);
        }
        if bearer != format!("Bearer {}", self.bearer) {
            return Err(Status::unauthenticated("this site is not known here"));
        }
        Ok(())
    }

    #[allow(clippy::too_many_arguments)]
    fn mint(&self, b: &mut Book, what: What, amount_minor: u64, setup: bool, success_url: &str, cancel_url: &str, reference: String) -> Checkout {
        let n = b.checkouts.len() + 1;
        let c = Checkout { id: format!("cs_test_fake{n:010}"), what, amount_minor, setup, success_url: success_url.to_owned(), cancel_url: cancel_url.to_owned(), reference, paid: false };
        b.checkouts.push(c.clone());
        c
    }

    fn step_of(sizes: &Option<pb::EstateSizes>) -> Option<&'static (&'static str, &'static str, u64, &'static str, u64)> {
        let main = sizes.as_ref().map(|s| s.main_plan.as_str()).unwrap_or("");
        LADDER.iter().find(|s| s.1 == main || (main.is_empty() && s.0 == "Mini"))
    }

    fn ticket_of(&self, call: &str, ticket: &[u8]) -> Result<(), Status> {
        let mut b = self.book();
        b.tickets.push((call.to_owned(), ticket.to_vec()));
        if ticket.is_empty() {
            return Err(Status::unauthenticated("no actor ticket: the product vouches for its buyer"));
        }
        if b.verifies_tickets && call.starts_with("Purchase.") && !b.spent.insert(ticket.to_vec()) {
            return Err(Status::permission_denied(SPENT));
        }
        Ok(())
    }
}

type R<T> = Result<Response<T>, Status>;

#[tonic::async_trait]
impl pb::signup_server::Signup for FakeOneBox {
    async fn status(&self, r: Request<pb::SignupStatusRequest>) -> R<pb::SignupStatusResponse> {
        self.hear("Signup.Status", &r)?;
        let r = r.into_inner();
        let mut b = self.book();
        let Some(c) = b.checkout(&r.session) else { return Err(Status::not_found("no such sign-up")) };
        let user = match &c.what {
            What::Signup { username, .. } => username.clone(),
            What::Estate { account, new_account: true, .. } => account.clone(),
            _ => return Err(Status::not_found("no such sign-up")),
        };
        if !c.paid {
            return Ok(Response::new(pb::SignupStatusResponse { state: pb::signup_status_response::State::Pending as i32, words: "Waiting for your payment to be confirmed. Nothing more is needed from you.".into(), reason: "the vendor's event has not landed".into(), ..Default::default() }));
        }
        // ★ Paid, and settling: the account is made on the product's main's next call.
        let id = c.id.clone();
        let polled = {
            let n = b.status_polls.entry(id).or_insert(0);
            *n += 1;
            *n
        };
        if b.status_fails.contains(&polled) {
            return Err(Status::unavailable("the box did not answer this poll"));
        }
        if polled <= b.settle_polls {
            let words = if b.maintenance { MAINTENANCE_WORDS } else { SETTLING_WORDS };
            let ahead = if b.maintenance { MAINTENANCE_WORDS } else { LONGER_WORDS };
            return Ok(Response::new(pb::SignupStatusResponse { state: pb::signup_status_response::State::Pending as i32, words: words.into(), words_if_silent: ahead.into(), silent_after_ms: SILENT_AFTER_MS, ..Default::default() }));
        }
        Ok(Response::new(pb::SignupStatusResponse { state: pb::signup_status_response::State::Created as i32, username: user, product: self.product.clone(), ..Default::default() }))
    }

    async fn check_name(&self, r: Request<pb::CheckNameRequest>) -> R<pb::CheckNameResponse> {
        self.hear("Signup.CheckName", &r)?;
        let name = r.into_inner().username;
        let free = name != "taken" && !self.book().accounts.contains_key(&name);
        Ok(Response::new(pb::CheckNameResponse { free, reason: if free { String::new() } else { format!("username {name:?} is taken; choose another") } }))
    }

    async fn start(&self, r: Request<pb::StartSignupRequest>) -> R<pb::StartSignupResponse> {
        self.hear("Signup.Start", &r)?;
        let r = r.into_inner();
        let mut b = self.book();
        b.emails.push(("Signup.Start".into(), r.email.clone()));
        b.urls.push(("Signup.Start".into(), r.success_url.clone()));
        b.urls.push(("Signup.Start".into(), r.cancel_url.clone()));
        if r.product != self.product {
            return Err(Status::invalid_argument("that product is not sold to this site"));
        }
        if r.username == "taken" || b.accounts.contains_key(&r.username) {
            return Err(Status::failed_precondition(format!("username {:?} is taken; choose another", r.username)));
        }
        let free = r.plan.is_empty() || r.plan == format!("{}/{FREE_ROW}", self.product);
        let paid = r.plan == format!("{}/{PAID_ROW}", self.product);
        if !free && !paid {
            return Err(Status::invalid_argument("that plan is not sold here"));
        }
        let what = What::Signup { username: r.username.clone(), plan: r.plan.clone(), email: r.email.clone() };
        let c = self.mint(&mut b, what, if free { 0 } else { PAID_MINOR }, free, &r.success_url, &r.cancel_url, format!("signup/{}", r.username));
        Ok(Response::new(pb::StartSignupResponse { url: c.url(), session: c.id }))
    }

    async fn rename(&self, r: Request<pb::RenameSignupRequest>) -> R<pb::RenameSignupResponse> {
        self.hear("Signup.Rename", &r)?;
        Err(Status::not_found("no such sign-up"))
    }

    async fn plans(&self, r: Request<pb::SignupPlansRequest>) -> R<pb::SignupPlansResponse> {
        self.hear("Signup.Plans", &r)?;
        self.stalled().await;
        let market = r.into_inner().market;
        Ok(Response::new(pb::SignupPlansResponse {
            plans: vec![
                pb::SignupPlanOffer { id: format!("{}/{FREE_ROW}", self.product), name: "Free account".into(), resources_words: "1 GB of room".into(), price_words: "Free".into(), cap_bytes: FREE_BYTES, period_days: 0 },
                pb::SignupPlanOffer { id: format!("{}/{PAID_ROW}", self.product), name: "10 GiB · 1 month".into(), resources_words: "10 GB of room".into(), price_words: PAID_WORDS.into(), cap_bytes: PAID_BYTES, period_days: 30 },
            ],
            market,
        }))
    }

    async fn send_sign_in_again(&self, r: Request<pb::SignupSignInAgainRequest>) -> R<pb::SignupSignInAgainResponse> {
        self.hear("Signup.SendSignInAgain", &r)?;
        let r = r.into_inner();
        let mut b = self.book();
        b.urls.push(("Signup.SendSignInAgain".into(), r.welcome_url.clone()));
        let Some(c) = b.checkout(&r.session).cloned() else { return Err(Status::not_found("no such sign-up")) };
        let user = match &c.what {
            What::Signup { username, .. } => username.clone(),
            What::Estate { account, .. } => account.clone(),
            _ => return Err(Status::not_found("no such sign-up")),
        };
        let to = b.accounts.get(&user).map(|a| a.email.clone()).ok_or_else(|| Status::failed_precondition("this account is not made yet"))?;
        b.mails.push(Mail { to, kind: "sign-in-again".into(), link: r.welcome_url.replace("{session}", &c.id) });
        Ok(Response::new(pb::SignupSignInAgainResponse { words: "We’ve sent it again to the address the account signed up with.".into() }))
    }
}

#[tonic::async_trait]
impl pb::estate_order_server::EstateOrder for FakeOneBox {
    async fn product_order(&self, _: tonic::Request<pb::EstateProductOrderRequest>) -> Result<tonic::Response<pb::EstateProductOrderResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order")) }
    async fn product_order_answer(&self, _: tonic::Request<pb::EstateProductOrderAnswerRequest>) -> Result<tonic::Response<pb::EstateProductOrderAnswerResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order_answer")) }
    async fn product_order_kinds(&self, _: tonic::Request<pb::EstateProductOrderKindsRequest>) -> Result<tonic::Response<pb::EstateProductOrderKindsResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order_kinds")) }
    async fn start(&self, r: Request<pb::EstateOrderStartRequest>) -> R<pb::EstateOrderStartResponse> {
        self.hear("EstateOrder.Start", &r)?;
        let r = r.into_inner();
        {
            let mut b = self.book();
            b.emails.push(("EstateOrder.Start".into(), r.email.clone()));
            for u in [&r.welcome_url, &r.cancel_url] {
                if !u.is_empty() {
                    b.urls.push(("EstateOrder.Start".into(), u.clone()));
                }
            }
        }
        if !r.new_account {
            self.ticket_of("EstateOrder.Start", &r.actor_ticket)?;
        }
        let mut b = self.book();
        if r.company == "taken" || b.estates.contains_key(&r.company) {
            return Err(Status::already_exists(format!("{}.{APEX} is taken", r.company)));
        }
        if r.new_account && (r.account == "taken" || b.accounts.contains_key(&r.account)) {
            return Err(Status::already_exists(format!("username {:?} is taken; choose another", r.account)));
        }
        let Some(step) = Self::step_of(&r.sizes) else { return Err(Status::invalid_argument("which size? that plan is not one sold here")) };
        let amount = step.4 * TERM_MONTHS;
        let success = if r.new_account { r.welcome_url.clone() } else { String::new() };
        let what = What::Estate { company: r.company.clone(), account: r.account.clone(), new_account: r.new_account, email: r.email.clone(), step: step.0.to_owned() };
        let c = self.mint(&mut b, what, amount, false, &success, &r.cancel_url, format!("estate/{}", r.company));
        Ok(Response::new(pb::EstateOrderStartResponse { reference: c.reference.clone(), checkout_url: c.url(), state: "waiting_for_payment".into(), words: "Waiting for your payment on the payment page.".into(), price_words: minor_words(amount, "EUR") }))
    }

    async fn status(&self, r: Request<pb::EstateOrderStatusRequest>) -> R<pb::EstateOrderStatusResponse> {
        self.hear("EstateOrder.Status", &r)?;
        let r = r.into_inner();
        let mut b = self.book();
        let paid = b.checkouts.iter().any(|c| c.paid && matches!(&c.what, What::Estate { company, .. } if *company == r.company));
        let held = b.checkouts.iter().any(|c| matches!(&c.what, What::Estate { company, .. } if *company == r.company));
        let Some(e) = b.estates.get_mut(&r.company) else {
            if held && !paid {
                return Ok(Response::new(pb::EstateOrderStatusResponse { company: r.company, state: "waiting_for_payment".into(), words: "Waiting for your payment on the payment page.".into(), ..Default::default() }));
            }
            return Err(Status::not_found("no such order"));
        };
        // Each poll lays it one step further: queued → laying → ready.
        e.state = match e.state.as_str() {
            "queued" => "laying",
            "laying" | "ready" => "ready",
            other => other,
        }
        .to_owned();
        let state = e.state.clone();
        let (change_status, change_words) = e.change.clone().map(|(step, st)| (st, format!("{} is {step} now.", r.company))).unwrap_or_default();
        let ready = state == "ready";
        let own = !r.actor_ticket.is_empty();
        let words = match state.as_str() {
            "ready" => format!("Your instance is ready at https://{}.{APEX}", r.company),
            "laying" => "Your machines and disks are being set up.".to_owned(),
            _ => "Your instance is paid and being prepared.".to_owned(),
        };
        Ok(Response::new(pb::EstateOrderStatusResponse {
            company: r.company.clone(),
            state,
            words,
            url: if ready && own { format!("https://{}.{APEX}", r.company) } else { String::new() },
            change_status,
            change_words,
            ..Default::default()
        }))
    }

    async fn send_sign_in_again(&self, r: Request<pb::EstateOrderSignInAgainRequest>) -> R<pb::EstateOrderSignInAgainResponse> {
        self.hear("EstateOrder.SendSignInAgain", &r)?;
        Ok(Response::new(pb::EstateOrderSignInAgainResponse { words: "sent".into() }))
    }

    async fn change_plan(&self, r: Request<pb::EstateOrderChangePlanRequest>) -> R<pb::EstateOrderChangePlanResponse> {
        self.hear("EstateOrder.ChangePlan", &r)?;
        let r = r.into_inner();
        self.ticket_of("EstateOrder.ChangePlan", &r.actor_ticket)?;
        let mut b = self.book();
        let Some(e) = b.estates.get(&r.company).cloned() else { return Err(Status::not_found("no such instance")) };
        let Some(step) = Self::step_of(&r.sizes) else { return Err(Status::invalid_argument("which size? that plan is not one sold here")) };
        if step.0 == e.step {
            return Err(Status::failed_precondition(format!("{} is {} already", r.company, e.step)));
        }
        let n = b.checkouts_of("change").len() + 1;
        let what = What::Change { company: r.company.clone(), step: step.0.to_owned() };
        let c = self.mint(&mut b, what, step.4, false, "", "", format!("estate/{}/change-{n}", r.company));
        if let Some(e) = b.estates.get_mut(&r.company) {
            e.change = Some((step.0.to_owned(), "ordered".into()));
        }
        Ok(Response::new(pb::EstateOrderChangePlanResponse { reference: c.reference.clone(), checkout_url: c.url(), status: "ordered".into(), words: "Pay the difference to grow it; the machines restart.".into(), price_words: minor_words(step.4, "EUR"), twin_plan: String::new() }))
    }

    async fn ladder(&self, r: Request<pb::EstateOrderLadderRequest>) -> R<pb::EstateOrderLadderResponse> {
        self.hear("EstateOrder.Ladder", &r)?;
        self.stalled().await;
        let r = r.into_inner();
        let current = if r.company.is_empty() {
            0
        } else {
            self.ticket_of("EstateOrder.Ladder", &r.actor_ticket)?;
            let b = self.book();
            let e = b.estates.get(&r.company).ok_or_else(|| Status::not_found("no such instance"))?;
            LADDER.iter().position(|s| s.0 == e.step).unwrap_or(0) as u32
        };
        Ok(Response::new(pb::EstateOrderLadderResponse {
            steps: LADDER
                .iter()
                .map(|s| pb::EstateLadderStep {
                    name: s.0.into(),
                    sizes: Some(pb::EstateSizes { front_plan: "f-mini".into(), main_plan: s.1.into(), main_data_gib: s.2, ..Default::default() }),
                    monthly_words: s.3.into(),
                    total_words: minor_words(s.4 * TERM_MONTHS, "EUR"),
                    // The real ladder's `pay_words` is the AMOUNT (`services/estate_order.rs`: `q.total.in_words()`);
                    // the page adds "Pay " (`buy.js` BUTTONS). A fake that said "Pay " read "Pay Pay …".
                    pay_words: minor_words(s.4 * TERM_MONTHS, "EUR"),
                    available: true,
                    ..Default::default()
                })
                .collect(),
            current,
            laid: !r.company.is_empty(),
            apex: APEX.into(),
            term_months: TERM_MONTHS as u32,
            ..Default::default()
        }))
    }

    async fn sells(&self, r: Request<pb::EstateOrderSellsRequest>) -> R<pb::EstateOrderSellsResponse> {
        self.hear("EstateOrder.Sells", &r)?;
        self.stalled().await;
        Ok(Response::new(pb::EstateOrderSellsResponse { estates: true }))
    }

    async fn purchases(&self, r: Request<pb::EstateOrderPurchasesRequest>) -> R<pb::EstateOrderPurchasesResponse> {
        self.hear("EstateOrder.Purchases", &r)?;
        let r = r.into_inner();
        self.ticket_of("EstateOrder.Purchases", &r.actor_ticket)?;
        let b = self.book();
        let estates = b
            .estates
            .iter()
            .filter(|(_, e)| e.account == r.account)
            .map(|(co, e)| {
                let step = LADDER.iter().find(|s| s.0 == e.step);
                pb::EstatePurchase { company: co.clone(), host: format!("{co}.{APEX}"), url: if e.state == "ready" { format!("https://{co}.{APEX}") } else { String::new() }, state: e.state.clone(), step: e.step.clone(), monthly_words: step.map(|s| s.3.to_owned()).unwrap_or_default(), ..Default::default() }
            })
            .collect();
        let acct = b.accounts.get(&r.account);
        let rooms = acct.filter(|a| !a.plan.is_empty()).map(|a| pb::RoomPurchase { state: "paid".into(), cap_bytes: a.caps.get("disk_bytes").copied().unwrap_or(0), ..Default::default() }).into_iter().collect();
        Ok(Response::new(pb::EstateOrderPurchasesResponse { estates, rooms, account_here: acct.is_some() }))
    }

    async fn renew(&self, r: Request<pb::EstateOrderRenewRequest>) -> R<pb::EstateOrderRenewResponse> {
        self.hear("EstateOrder.Renew", &r)?;
        Err(Status::unimplemented("Renew"))
    }
    async fn notice(&self, _: Request<pb::EstateNoticeRequest>) -> R<pb::EstateNoticeResponse> {
        Err(Status::unimplemented("Notice"))
    }
    async fn cancel(&self, _: Request<pb::EstateOrderCancelRequest>) -> R<pb::EstateOrderCancelResponse> {
        Err(Status::unimplemented("Cancel"))
    }
    async fn settle(&self, _: Request<pb::EstateOrderSettleRequest>) -> R<pb::EstateOrderSettleResponse> {
        Err(Status::unimplemented("Settle"))
    }
    async fn restore_front(&self, _: Request<pb::EstateOrderRestoreFrontRequest>) -> R<pb::EstateOrderRestoreFrontResponse> {
        Err(Status::unimplemented("RestoreFront"))
    }
    async fn terminate(&self, _: Request<pb::EstateOrderTerminateRequest>) -> R<pb::EstateOrderTerminateResponse> {
        Err(Status::unimplemented("Terminate"))
    }
    async fn first_link(&self, _: Request<pb::EstateOrderFirstLinkRequest>) -> R<pb::EstateOrderFirstLinkResponse> {
        Err(Status::unimplemented("FirstLink"))
    }
    async fn refeed(&self, _: Request<pb::EstateOrderRefeedRequest>) -> R<pb::EstateOrderRefeedResponse> {
        Err(Status::unimplemented("Refeed"))
    }
    async fn console(&self, _: Request<pb::EstateOrderConsoleRequest>) -> R<pb::EstateOrderConsoleResponse> {
        Err(Status::unimplemented("Console"))
    }
    async fn rebuild_main(&self, _: Request<pb::EstateRebuildMainRequest>) -> R<pb::EstateRebuildMainResponse> {
        Err(Status::unimplemented("RebuildMain"))
    }
    async fn order_site(&self, _: Request<pb::EstateOrderSiteRequest>) -> R<pb::EstateOrderSiteResponse> {
        Err(Status::unimplemented("OrderSite"))
    }
    async fn list_sites(&self, _: Request<pb::EstateSitesRequest>) -> R<pb::EstateSitesResponse> {
        Err(Status::unimplemented("ListSites"))
    }
    async fn update_estate(&self, _: Request<pb::EstateUpdateRequest>) -> R<pb::EstateUpdateResponse> {
        Err(Status::unimplemented("UpdateEstate"))
    }
    async fn update_status(&self, _: Request<pb::EstateUpdateStatusRequest>) -> R<pb::EstateUpdateResponse> {
        Err(Status::unimplemented("UpdateStatus"))
    }
    async fn cancel_update(&self, _: Request<pb::EstateUpdateStatusRequest>) -> R<pb::EstateUpdateResponse> { Err(Status::unimplemented("cancel_update")) }
    async fn set_live_update(&self, _: Request<pb::EstateSetLiveUpdateRequest>) -> R<pb::EstateUpdateResponse> {
        Err(Status::unimplemented("SetLiveUpdate"))
    }
    async fn adopt_estate(&self, _: Request<pb::EstateAdoptRequest>) -> R<pb::EstateAdoptResponse> {
        Err(Status::unimplemented("AdoptEstate"))
    }
    async fn list_code_red(&self, _: Request<pb::CodeRedListRequest>) -> R<pb::CodeRedList> {
        Err(Status::unimplemented("ListCodeRed"))
    }
    async fn ack_code_red(&self, _: Request<pb::CodeRedAckRequest>) -> R<pb::CodeRedList> {
        Err(Status::unimplemented("AckCodeRed"))
    }
    async fn raise_code_red(&self, _: Request<pb::CodeRed>) -> R<pb::CodeRedList> {
        Err(Status::unimplemented("RaiseCodeRed"))
    }
    async fn clear_code_red(&self, _: Request<pb::CodeRedClearRequest>) -> R<pb::CodeRedList> {
        Err(Status::unimplemented("ClearCodeRed"))
    }
    async fn narrate_log(&self, _: Request<pb::EstateNarrateLogRequest>) -> R<pb::EstateNarrateLogResponse> {
        Err(Status::unimplemented("NarrateLog"))
    }
    async fn install_log(&self, _: Request<pb::EstateInstallLogRequest>) -> R<pb::EstateInstallLogResponse> {
        Err(Status::unimplemented("InstallLog"))
    }
}

#[tonic::async_trait]
impl pb::purchase_server::Purchase for FakeOneBox {
    async fn bundles(&self, r: Request<pb::ListBundlesRequest>) -> R<pb::ListBundlesResponse> {
        self.hear("Purchase.Bundles", &r)?;
        let r = r.into_inner();
        self.ticket_of("Purchase.Bundles", &r.actor_ticket)?;
        let b = self.book();
        let plan = b.accounts.get(&r.tenant).map(|a| if a.plan.is_empty() { "none" } else { "paid" }).unwrap_or("none");
        Ok(Response::new(pb::ListBundlesResponse {
            bundles: b.bundles.clone().unwrap_or_else(|| {
                vec![
                    pb::Bundle { id: BUNDLE.into(), label: "10.0 GiB more room".into(), price: BUNDLE_WORDS.into(), period: "month".into(), delta: [("disk_bytes".to_string(), BUNDLE_BYTES)].into_iter().collect(), can_serve: true, ..Default::default() },
                    pb::Bundle { id: "huge".into(), label: "1 TiB more room".into(), can_serve: false, reason: "no room".into(), ..Default::default() },
                ]
            }),
            reading: false,
            plan: Some(pb::PlanStatus { state: plan.into(), ..Default::default() }),
        }))
    }

    async fn start(&self, r: Request<pb::StartPurchaseRequest>) -> R<pb::StartPurchaseResponse> {
        self.hear("Purchase.Start", &r)?;
        let r = r.into_inner();
        self.ticket_of("Purchase.Start", &r.actor_ticket)?;
        let mut b = self.book();
        if r.bundle != BUNDLE {
            return Err(Status::invalid_argument("that is not something this account can order right now"));
        }
        if !r.caps.is_empty() {
            return Err(Status::invalid_argument("the caps ride in the ticket, never beside a bundle"));
        }
        let n = b.purchases.len() + 1;
        let reference = format!("p-{n}");
        let what = What::Bundle { tenant: r.tenant.clone(), bundle: r.bundle.clone() };
        let c = self.mint(&mut b, what, BUNDLE_MINOR, false, "", "", reference.clone());
        let i = b.checkouts.len() - 1;
        b.purchases.insert(reference.clone(), i);
        Ok(Response::new(pb::StartPurchaseResponse { reference, state: pb::PurchaseState::Pending as i32, redirect_url: c.url(), ..Default::default() }))
    }

    async fn status(&self, r: Request<pb::PurchaseStatusRequest>) -> R<pb::PurchaseStatusResponse> {
        self.hear("Purchase.Status", &r)?;
        let r = r.into_inner();
        self.ticket_of("Purchase.Status", &r.actor_ticket)?;
        let b = self.book();
        let Some(&i) = b.purchases.get(&r.reference) else { return Err(Status::not_found("no such purchase")) };
        let c = &b.checkouts[i];
        if !matches!(&c.what, What::Bundle { tenant, .. } if *tenant == r.tenant) {
            return Err(Status::not_found("no such purchase"));
        }
        if b.cancelled.contains(&r.reference) {
            return Ok(Response::new(pb::PurchaseStatusResponse { reference: r.reference, state: pb::PurchaseState::Released as i32, phase: pb::PurchasePhase::Cancelled as i32, ..Default::default() }));
        }
        if !c.paid {
            return Ok(Response::new(pb::PurchaseStatusResponse { reference: r.reference, state: pb::PurchaseState::Pending as i32, phase: pb::PurchasePhase::WaitingForPayment as i32, redirect_url: c.url(), cancellable: true, ..Default::default() }));
        }
        let caps = b.accounts.get(&r.tenant).map(|a| a.caps.clone()).unwrap_or_default();
        Ok(Response::new(pb::PurchaseStatusResponse {
            reference: r.reference,
            state: pb::PurchaseState::Paid as i32,
            phase: pb::PurchasePhase::Done as i32,
            fact: Some(pb::EntitlementFact { tenant: r.tenant, caps: caps.into_iter().collect(), ..Default::default() }),
            ..Default::default()
        }))
    }

    async fn quote(&self, _: Request<pb::QuoteRequest>) -> R<pb::QuoteResponse> {
        Err(Status::unimplemented("Quote"))
    }
    async fn abandon(&self, _: Request<pb::AbandonPurchaseRequest>) -> R<pb::AbandonPurchaseResponse> {
        Err(Status::unimplemented("Abandon"))
    }
    /// "Cancel order": the buyer closes their own unpaid purchase; asked again it is already
    /// closed; a paid one is not cancelled here.
    async fn cancel(&self, r: Request<pb::CancelPurchaseRequest>) -> R<pb::CancelPurchaseResponse> {
        self.hear("Purchase.Cancel", &r)?;
        let r = r.into_inner();
        self.ticket_of("Purchase.Cancel", &r.actor_ticket)?;
        let mut b = self.book();
        let Some(&i) = b.purchases.get(&r.reference) else { return Err(Status::not_found("no such purchase")) };
        let c = b.checkouts[i].clone();
        if !matches!(&c.what, What::Bundle { tenant, .. } if *tenant == r.tenant) {
            return Err(Status::not_found("no such purchase"));
        }
        if c.paid {
            return Err(Status::failed_precondition("this purchase is paid: it is not cancelled here"));
        }
        let already_closed = !b.cancelled.insert(r.reference.clone());
        Ok(Response::new(pb::CancelPurchaseResponse { reference: r.reference, already_closed, state: pb::PurchaseState::Released as i32, phase: pb::PurchasePhase::Cancelled as i32, reason: r.reason, ..Default::default() }))
    }
    async fn history(&self, _: Request<pb::PurchaseHistoryRequest>) -> R<pb::PurchaseHistoryResponse> {
        Err(Status::unimplemented("History"))
    }
}

/// `Products.List`: the one product this box sells, and whether it verifies its tenant tickets.
#[tonic::async_trait]
impl pb::products_server::Products for FakeOneBox {
    async fn list(&self, r: Request<pb::ListProductsRequest>) -> R<pb::ListProductsResponse> {
        self.hear("Products.List", &r)?;
        let tenant_renewal = !self.book().checks_no_tickets;
        Ok(Response::new(pb::ListProductsResponse { products: vec![pb::ProductInfo { id: self.product.clone(), tenant_renewal, ..Default::default() }], ..Default::default() }))
    }
}

#[tonic::async_trait]
impl pb::entitlement_server::Entitlement for FakeOneBox {
    async fn get(&self, r: Request<pb::GetEntitlementRequest>) -> R<pb::GetEntitlementResponse> {
        self.hear("Entitlement.Get", &r)?;
        let r = r.into_inner();
        let b = self.book();
        let fact = b.accounts.get(&r.tenant).map(|a| pb::EntitlementFact { tenant: r.tenant.clone(), caps: a.caps.clone().into_iter().collect(), ..Default::default() });
        Ok(Response::new(pb::GetEntitlementResponse { fact, pending_push: None }))
    }
    async fn list(&self, _: Request<pb::ListEntitlementsRequest>) -> R<pb::ListEntitlementsResponse> {
        Err(Status::unimplemented("List"))
    }
    async fn set(&self, _: Request<pb::SetEntitlementRequest>) -> R<pb::SetEntitlementResponse> {
        Err(Status::unimplemented("Set"))
    }
    async fn public_key(&self, _: Request<pb::PublicKeyRequest>) -> R<pb::PublicKeyResponse> {
        Err(Status::unimplemented("PublicKey"))
    }
    async fn held(&self, _: Request<pb::HeldRequest>) -> R<pb::HeldResponse> {
        Err(Status::unimplemented("Held"))
    }
}

/// **The caps `tenant` holds at the fake**, asked over the wire (`Entitlement.Get`), the way a
/// site that reads its held caps at the ONE box asks.
pub async fn held_caps_at(url: &str, bearer: &str, product: &str, tenant: &str) -> Result<BTreeMap<String, u64>, Status> {
    let channel = tonic::transport::Channel::from_shared(url.to_owned()).map_err(|e| Status::internal(e.to_string()))?.connect().await.map_err(|e| Status::unavailable(e.to_string()))?;
    let bearer: tonic::metadata::MetadataValue<tonic::metadata::Ascii> = format!("Bearer {bearer}").parse().map_err(|_| Status::internal("bearer"))?;
    let mut client = pb::entitlement_client::EntitlementClient::with_interceptor(channel, move |mut r: Request<()>| {
        r.metadata_mut().insert("authorization", bearer.clone());
        Ok(r)
    });
    let fact = client.get(pb::GetEntitlementRequest { product: product.to_owned(), tenant: tenant.to_owned() }).await?.into_inner().fact;
    Ok(fact.map(|f| f.caps.into_iter().collect()).unwrap_or_default())
}

// ── two product sites, for a relay over any ONE box (this fake, or a real monetize) ──────

/// **The shape of a product site**: Gunnar's console (its session cookie, its pages) or
/// Holger's front (the person's bearer, its pages). Only the provider differs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Shape {
    Gunnar,
    Holger,
}

impl Shape {
    pub fn product(self) -> &'static str {
        match self {
            Shape::Gunnar => "gunnar",
            Shape::Holger => "holger",
        }
    }
    pub fn word(self) -> &'static str {
        match self {
            Shape::Gunnar => "Gunnar",
            Shape::Holger => "Holger",
        }
    }
    pub fn origin(self) -> &'static str {
        match self {
            Shape::Gunnar => "https://gunnar.mock.invalid",
            Shape::Holger => "https://holger.mock.invalid",
        }
    }
    pub fn host(self) -> &'static str {
        self.origin().trim_start_matches("https://")
    }
    /// The site's pages: gunnar-ui-server `auth::routes::SIGNUP_ACCOUNT` / `SIGNUP_PRIVATE`,
    /// holger-estate-front `HolgerSite::pages`.
    pub fn pages(self) -> crate::Pages {
        match self {
            Shape::Gunnar => crate::Pages { account: "/signup?plan=10gb".into(), private: "/signup?plan=private".into(), manage: "/signup?plan=private&renew=".into() },
            Shape::Holger => crate::Pages { manage: "/private?renew=".into(), ..crate::Pages::default() },
        }
    }
    /// The session header a signed-in request carries.
    pub fn session(self, user: &str) -> (&'static str, String) {
        match self {
            Shape::Gunnar => ("cookie", format!("gunnar_session={user}")),
            Shape::Holger => ("authorization", format!("Bearer {user}")),
        }
    }
}

/// How a test site vouches for its buyer: `(buyer, purpose, caps) → ticket`.
pub type Minter = Arc<dyn Fn(&crate::Buyer, &str, &BTreeMap<String, u64>) -> Vec<u8> + Send + Sync>;
/// Whether the product holds the account `user` yet.
pub type Holds = Arc<dyn Fn(&str) -> bool + Send + Sync>;
/// The caps the product says `tenant` holds (instead of asking `Entitlement.Get`).
pub type Held = Arc<dyn Fn(&str) -> BTreeMap<String, u64> + Send + Sync>;

/// **A test product site** of a [`Shape`]: who asks is the session header's name
/// (`member:<co>` a member of `co`, `machine` no person, `down` the product unreachable); its
/// tickets are [`TestSite::minted`]'s; its held caps are the ONE box's `Entitlement.Get`.
#[derive(Clone)]
pub struct TestSite {
    pub shape: Shape,
    /// Where `Entitlement.Get` is asked, with which bearer, for which product.
    pub shop: (String, String, String),
    /// Asks per door and client address (`None`: no budget).
    pub budget: Option<u32>,
    spent: Arc<Mutex<std::collections::HashMap<String, u32>>>,
    /// Accounts that hold a passkey here already.
    pub passkeys: Arc<Mutex<Vec<String>>>,
    /// Every ticket the site minted: (purpose, account, caps).
    #[allow(clippy::type_complexity)]
    pub minted: Arc<Mutex<Vec<(String, String, BTreeMap<String, u64>)>>>,
    mint: Option<Minter>,
    holds: Holds,
    held: Option<Held>,
}

impl TestSite {
    /// The site over this fake ONE box (its tickets plain words, its accounts the fake's).
    pub fn over(shape: Shape, fake: &FakeOneBox) -> TestSite {
        let f = fake.clone();
        TestSite::at(shape, &fake.url(), &fake.bearer, Arc::new(move |u: &str| f.book().accounts.contains_key(u)))
    }

    /// The site over the ONE box at `url` with `bearer`; `holds` answers whether the product
    /// holds an account.
    pub fn at(shape: Shape, url: &str, bearer: &str, holds: Holds) -> TestSite {
        TestSite { shape, shop: (url.to_owned(), bearer.to_owned(), shape.product().to_owned()), budget: None, spent: Arc::default(), passkeys: Arc::default(), minted: Arc::default(), mint: None, holds, held: None }
    }

    pub fn with_budget(mut self, budget: Option<u32>) -> TestSite {
        self.budget = budget;
        self
    }

    /// Tickets minted by `mint` (a real ONE box verifies them).
    pub fn with_minter(mut self, mint: Minter) -> TestSite {
        self.mint = Some(mint);
        self
    }

    /// Held caps from `held` (the product's own word) rather than the ONE box's.
    pub fn with_held(mut self, held: Held) -> TestSite {
        self.held = Some(held);
        self
    }

    fn name_of(&self, headers: &axum::http::HeaderMap) -> Option<String> {
        match self.shape {
            Shape::Gunnar => headers.get("cookie").and_then(|v| v.to_str().ok()).and_then(|c| c.split(';').find_map(|p| p.trim().strip_prefix("gunnar_session=").map(str::to_owned))),
            Shape::Holger => headers.get("authorization").and_then(|v| v.to_str().ok()).and_then(|a| a.strip_prefix("Bearer ").map(str::to_owned)),
        }
    }
}

impl crate::BuySite for TestSite {
    fn origin(&self) -> String {
        self.shape.origin().to_owned()
    }
    fn product_word(&self) -> String {
        self.shape.word().to_owned()
    }
    fn pages(&self) -> crate::Pages {
        self.shape.pages()
    }
    fn allowed(&self, door: &str, ip: &str, _key: &str) -> bool {
        let Some(budget) = self.budget else { return true };
        let mut spent = self.spent.lock().unwrap_or_else(|p| p.into_inner());
        let n = spent.entry(format!("{door}|{ip}")).or_insert(0);
        *n += 1;
        *n <= budget
    }
    async fn who(&self, headers: &axum::http::HeaderMap) -> Result<crate::Who, crate::Refusal> {
        use crate::{Buyer, Member, Who};
        Ok(match self.name_of(headers).as_deref() {
            None => Who::Visitor,
            Some("machine") => Who::Machine,
            Some("down") => return Err(crate::Refusal::new(axum::http::StatusCode::SERVICE_UNAVAILABLE, format!("{} cannot be reached right now: try again in a minute.", self.shape.word()))),
            Some(m) if m.starts_with("member:") => {
                let co = &m["member:".len()..];
                Who::Person(Buyer { account: "ann".into(), member: Some(Member { account: co.into(), admin: "boss".into(), line: format!("You are a member of {co}.") }), ..Buyer::default() })
            }
            Some(a) => Who::Person(Buyer { account: a.into(), ..Buyer::default() }),
        })
    }
    async fn ticket(&self, _: &axum::http::HeaderMap, buyer: &crate::Buyer, purpose: &str, caps: &BTreeMap<String, u64>) -> Result<Vec<u8>, crate::Refusal> {
        self.minted.lock().unwrap_or_else(|p| p.into_inner()).push((purpose.to_owned(), buyer.account.clone(), caps.clone()));
        Ok(match &self.mint {
            Some(mint) => mint(buyer, purpose, caps),
            None => format!("{}:{purpose}:{}:{}", self.shape.product(), buyer.account, serde_json::to_string(caps).unwrap_or_default()).into_bytes(),
        })
    }
    async fn held_caps(&self, _: &axum::http::HeaderMap, buyer: &crate::Buyer) -> Result<BTreeMap<String, u64>, crate::Refusal> {
        if let Some(held) = &self.held {
            return Ok(held(buyer.tenant()));
        }
        let (url, bearer, product) = &self.shop;
        held_caps_at(url, bearer, product, buyer.tenant()).await.map_err(|_| crate::Refusal::new(axum::http::StatusCode::SERVICE_UNAVAILABLE, "the room held cannot be read right now"))
    }
    async fn signup_account(&self, user: &str) -> Result<crate::Account, crate::Refusal> {
        if !(self.holds)(user) {
            return Err(crate::Refusal::new(axum::http::StatusCode::CONFLICT, "not here yet"));
        }
        Ok(if self.passkeys.lock().unwrap_or_else(|p| p.into_inner()).iter().any(|p| p == user) { crate::Account::HasPasskey } else { crate::Account::NoPasskey })
    }
}