//! Admin product CRUD.
//!
//! A product is a presentation grouping; its purchasable options live in
//! `product_variants` (each with its own label, sku, stock, regular price and
//! optional public/business quick-sale prices), edited inline on the product
//! form. The products list and the per-audience percentage discount profiles
//! operate at the product level, previewing prices on each product's
//! representative (first) variant.

use std::collections::{HashMap, HashSet};

use axum::extract::{DefaultBodyLimit, Multipart};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{
    ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
    QueryOrder, Set, TransactionTrait,
};
use serde_json::json;

use crate::{
    controllers::{
        admin_form::{read_multipart_form, store_image, ImageSlot, MultipartForm},
        i18n::current_lang,
        media::IMAGE_MAX_BYTES,
    },
    shared::{
        guard,
        money::{format_bp, format_price, parse_percent, parse_price_to_cents},
        pricing,
        slug::{slugify, unique_slug},
    },
    models::{
        audience_discount_profiles, categories, discount_profiles, product_images,
        product_variants, products,
    },
    views::shop as view,
};

/// Which discount column an audience tab operates on.
const BUSINESS: &str = "business";

async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
    products::Entity::find_by_id(id)
        .one(&ctx.db)
        .await?
        .ok_or_else(|| Error::NotFound)
}

/// Fields parsed from a product form, ready to apply to an active model.
struct ProductFields {
    name: String,
    slug: String,
    description: Option<String>,
    short_description: Option<String>,
    category_id: Option<i32>,
    published: bool,
}

async fn parse_product_fields(
    ctx: &AppContext,
    form: &MultipartForm,
    current_id: Option<i32>,
) -> Result<ProductFields> {
    let name = form
        .text("name")
        .ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
    let description = form.text("description");
    let short_description = form.text("short_description");
    let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
    let published = form.checked("published");

    let desired = form
        .text("slug")
        .map(|s| slugify(&s))
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| slugify(&name));
    let slug = unique_slug(&desired, |candidate| {
        let ctx = ctx.clone();
        async move {
            let mut query = products::Entity::find().filter(products::Column::Slug.eq(candidate));
            if let Some(id) = current_id {
                query = query.filter(products::Column::Id.ne(id));
            }
            Ok(query.count(&ctx.db).await? > 0)
        }
    })
    .await?;

    Ok(ProductFields {
        name,
        slug,
        description,
        short_description,
        category_id,
        published,
    })
}

/// One variant row parsed from the product form.
struct VariantInput {
    id: Option<i32>,
    label: String,
    sku: Option<String>,
    /// `None` = available but not inventory-tracked.
    stock: Option<i32>,
    price_cents: i64,
    position: i32,
}

/// Parse the repeated variant rows from the form, in submission order. Blank
/// rows (no price and no label) are skipped; at least one valid row is required.
fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
    let mut out = Vec::new();
    for i in form.variant_indices() {
        let label = form
            .text(&format!("variants[{i}][label]"))
            .unwrap_or_default();
        let price_raw = form.text(&format!("variants[{i}][price]"));

        let Some(price_raw) = price_raw else {
            // A completely empty leftover row is ignored; a labelled row without
            // a price is a mistake worth reporting.
            if label.is_empty() {
                continue;
            }
            return Err(Error::BadRequest(
                "each option needs a price".to_string(),
            ));
        };
        let price_cents = parse_price_to_cents(&price_raw)?;
        if price_cents <= 0 {
            return Err(Error::BadRequest(
                "an option price must be positive".to_string(),
            ));
        }

        let sku = form.text(&format!("variants[{i}][sku]"));
        // Stock is optional: blank means "available, not tracked". A value must
        // be a non-negative integer.
        let stock = match form.text(&format!("variants[{i}][stock]")) {
            None => None,
            Some(raw) => Some(
                raw.parse::<i32>()
                    .ok()
                    .filter(|n| *n >= 0)
                    .ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
            ),
        };
        let id = form
            .text(&format!("variants[{i}][id]"))
            .and_then(|s| s.parse::<i32>().ok());

        out.push(VariantInput {
            id,
            label,
            sku,
            stock,
            price_cents,
            position: out.len() as i32,
        });
    }
    if out.is_empty() {
        return Err(Error::BadRequest(
            "add at least one option with a price".to_string(),
        ));
    }
    Ok(out)
}

/// Apply a parsed variant row onto a (new or existing) active model.
fn apply_variant(active: &mut product_variants::ActiveModel, input: &VariantInput) {
    active.label = Set(input.label.clone());
    active.sku = Set(input.sku.clone());
    active.stock = Set(input.stock);
    active.price_cents = Set(input.price_cents);
    // Discounts (public + business sale) are owned by the discount page and keyed
    // per option/audience; the product form must leave those columns untouched so
    // it never clobbers a discount. New variants default them to NULL.
    active.position = Set(input.position);
}

/// Reconcile the product's variants with the submitted rows inside `txn`: update
/// rows carrying an id, insert rows without one, and delete existing variants no
/// longer present.
async fn sync_variants<C: ConnectionTrait>(
    txn: &C,
    product_id: i32,
    inputs: &[VariantInput],
) -> Result<()> {
    let existing = product_variants::Entity::for_product(txn, product_id).await?;
    let keep: HashSet<i32> = inputs.iter().filter_map(|v| v.id).collect();
    for variant in &existing {
        if !keep.contains(&variant.id) {
            variant.clone().delete(txn).await?;
        }
    }
    let by_id: HashMap<i32, product_variants::Model> =
        existing.into_iter().map(|v| (v.id, v)).collect();
    for input in inputs {
        match input.id.and_then(|id| by_id.get(&id)) {
            Some(model) => {
                let mut active = model.clone().into_active_model();
                apply_variant(&mut active, input);
                active.update(txn).await?;
            }
            None => {
                let mut active = product_variants::ActiveModel {
                    product_id: Set(product_id),
                    ..Default::default()
                };
                apply_variant(&mut active, input);
                active.insert(txn).await?;
            }
        }
    }
    Ok(())
}

async fn form_context(ctx: &AppContext, jar: &CookieJar) -> Result<serde_json::Value> {
    let categories = categories::Entity::find()
        .order_by_asc(categories::Column::Position)
        .order_by_asc(categories::Column::Name)
        .all(&ctx.db)
        .await?;
    Ok(json!({ "categories": categories, "lang": current_lang(jar) }))
}

/// Variant shape used to pre-fill a row of the product form's variant editor.
fn variant_form_json(variant: &product_variants::Model) -> serde_json::Value {
    json!({
        "id": variant.id,
        "label": variant.label,
        "sku": variant.sku,
        "stock": variant.stock,
        "price": format_price(variant.price_cents),
    })
}

#[debug_handler]
async fn index(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Query(params): Query<HashMap<String, String>>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    guard::current_admin(auth, &ctx).await?;
    let audience = read_audience(&params);
    let all_categories = categories::Entity::find()
        .order_by_asc(categories::Column::Position)
        .order_by_asc(categories::Column::Name)
        .all(&ctx.db)
        .await?;
    let category_name: HashMap<i32, String> = all_categories
        .iter()
        .map(|c| (c.id, c.name.clone()))
        .collect();

    // Optional text search (drafts included), otherwise the full catalog newest
    // first. Reuses the storefront's hybrid full-text + fuzzy product search.
    let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
    let list = if query.is_empty() {
        products::Entity::find()
            .order_by_desc(products::Column::CreatedAt)
            .all(&ctx.db)
            .await?
    } else {
        products::Entity::search(&ctx.db, &query, 1000, false).await?
    };
    let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
    let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;

    // Category sidebar tree (counts over the full, unfiltered list) plus the
    // active `?category=` filter applied to the rows.
    let category_ids: Vec<Option<i32>> = list.iter().map(|p| p.category_id).collect();
    let category_groups = view::admin_category_groups(&all_categories, &category_ids);
    let selected_category = params
        .get("category")
        .map(String::as_str)
        .unwrap_or("all")
        .to_string();
    let filter = view::category_filter_ids(&all_categories, &selected_category);

    // The kept products with their representative (first) variant, priced in one
    // batch for the active audience.
    let mut kept: Vec<(&products::Model, &Vec<product_variants::Model>)> = Vec::new();
    for product in &list {
        if !view::category_filter_keep(&filter, product.category_id) {
            continue;
        }
        if let Some(variants) = grouped.get(&product.id) {
            if !variants.is_empty() {
                kept.push((product, variants));
            }
        }
    }
    let reps: Vec<product_variants::Model> =
        kept.iter().map(|(_, vs)| vs[0].clone()).collect();
    let effective = pricing::audience_price_variants(&ctx, &reps, audience).await?;

    let mut rows = Vec::new();
    for ((product, variants), priced) in kept.iter().zip(effective.iter()) {
        let image = product_images::first_for(&ctx, product.id).await?;
        let category_name = product
            .category_id
            .and_then(|id| category_name.get(&id).cloned());
        // Stock column: total across tracked variants, or "∞" when any option is
        // untracked (always available).
        let stock_display = if variants.iter().any(|v| !v.tracked()) {
            "∞".to_string()
        } else {
            variants
                .iter()
                .filter_map(|v| v.stock)
                .sum::<i32>()
                .to_string()
        };
        // The product is "on sale" for this audience if any option carries a
        // discount; the per-option amounts live on the discount page.
        let on_sale = variants.iter().any(|v| current_value(v, audience).is_some());
        rows.push(product_row(
            product,
            priced,
            on_sale,
            variants.len(),
            stock_display,
            image,
            category_name,
        ));
    }

    format::view(
        &v,
        "admin/catalog/products.html",
        json!({
            "products": rows,
            "profiles": load_audience_profiles(&ctx, audience).await?,
            "audience": audience,
            "category_groups": category_groups,
            "selected_category": selected_category,
            "query": query,
            "total_count": list.len(),
            "uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
            "lang": current_lang(&jar),
        }),
    )
}

/// List-row shape: the product card fields plus its representative variant's
/// resolved effective price (after the active audience's profiles) and the count
/// of options.
fn product_row(
    product: &products::Model,
    effective: &pricing::PricedProduct,
    on_sale: bool,
    variant_count: usize,
    stock_display: String,
    image: Option<String>,
    category_name: Option<String>,
) -> serde_json::Value {
    json!({
        "id": product.id,
        "name": product.name,
        "slug": product.slug,
        "stock": stock_display,
        "variant_count": variant_count,
        "has_options": variant_count > 1,
        "published": product.published,
        "image": image,
        "category_name": category_name,
        "regular_price": format_price(effective.regular_cents),
        "on_sale": on_sale,
        "effective_price": format_price(effective.price_cents),
        "effective_reduced": effective.is_reduced(),
        "effective_percent_off": percent_off(effective.regular_cents, effective.price_cents),
    })
}

/// All discount profiles, flagged with whether they are assigned to `audience`.
async fn load_audience_profiles(
    ctx: &AppContext,
    audience: &str,
) -> Result<Vec<serde_json::Value>> {
    let assigned: HashSet<i32> = audience_discount_profiles::Entity::find()
        .filter(audience_discount_profiles::Column::Audience.eq(audience))
        .all(&ctx.db)
        .await?
        .into_iter()
        .map(|a| a.discount_profile_id)
        .collect();
    let all_profiles = discount_profiles::Entity::find()
        .order_by_asc(discount_profiles::Column::Name)
        .all(&ctx.db)
        .await?;
    Ok(all_profiles
        .iter()
        .map(|p| {
            json!({
                "id": p.id,
                "name": p.name,
                "percent": format_bp(p.percent_bp),
                "scope_type": p.scope_type,
                "assigned": assigned.contains(&p.id),
            })
        })
        .collect())
}

#[debug_handler]
async fn new(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    guard::current_admin(auth, &ctx).await?;
    let mut context = form_context(&ctx, &jar).await?;
    context["product"] = serde_json::Value::Null;
    context["variants"] = json!([]);
    format::view(&v, "admin/catalog/product_form.html", context)
}

#[debug_handler]
async fn create(
    auth: auth::JWT,
    State(ctx): State<AppContext>,
    multipart: Multipart,
) -> Result<Response> {
    guard::current_admin(auth, &ctx).await?;
    let form = read_multipart_form(multipart).await?;
    let fields = parse_product_fields(&ctx, &form, None).await?;
    let variants = parse_variants(&form)?;

    let txn = ctx.db.begin().await?;
    let product = products::ActiveModel {
        name: Set(fields.name),
        slug: Set(fields.slug),
        description: Set(fields.description),
        short_description: Set(fields.short_description),
        view_count: Set(0),
        published: Set(fields.published),
        published_at: Set(fields.published.then(|| chrono::Utc::now().into())),
        category_id: Set(fields.category_id),
        ..Default::default()
    }
    .insert(&txn)
    .await?;
    sync_variants(&txn, product.id, &variants).await?;
    sync_images(&ctx, &txn, product.id, &form.image_order, &form.images).await?;
    txn.commit().await?;

    format::redirect("/admin/catalog/products")
}

/// Reconcile a product's images inside `txn` with the submitted unified
/// `image_order`: for each [`ImageSlot::Existing`] entry re-number the
/// corresponding image to its slot position; for each [`ImageSlot::New`]
/// consume the next freshly uploaded file from `new_images`, storing and
/// inserting it at that position. Any existing image not referenced in
/// `image_order` is deleted.
async fn sync_images<C: ConnectionTrait>(
    ctx: &AppContext,
    txn: &C,
    product_id: i32,
    image_order: &[ImageSlot],
    new_images: &[Vec<u8>],
) -> Result<()> {
    let existing = product_images::for_product(txn, product_id).await?;
    let by_id: HashMap<i32, product_images::Model> =
        existing.iter().map(|m| (m.id, m.clone())).collect();
    let keep: HashSet<i32> = image_order
        .iter()
        .filter_map(|slot| match slot {
            ImageSlot::Existing(id) => Some(*id),
            ImageSlot::New => None,
        })
        .collect();

    for image in &existing {
        if !keep.contains(&image.id) {
            image.clone().delete(txn).await?;
        }
    }

    let mut new_iter = new_images.iter();
    let mut position = 0i32;
    for slot in image_order {
        match slot {
            ImageSlot::Existing(id) => {
                if let Some(model) = by_id.get(id) {
                    let mut active = model.clone().into_active_model();
                    active.position = Set(position);
                    active.update(txn).await?;
                }
            }
            ImageSlot::New => {
                if let Some(data) = new_iter.next() {
                    let filename = store_image(ctx, data.clone()).await?;
                    product_images::ActiveModel {
                        product_id: Set(product_id),
                        image_id: Set(filename),
                        position: Set(position),
                        alt: Set(None),
                        ..Default::default()
                    }
                    .insert(txn)
                    .await?;
                }
            }
        }
        position += 1;
    }
    Ok(())
}

#[debug_handler]
async fn edit(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    guard::current_admin(auth, &ctx).await?;
    let product = product_by_id(&ctx, id).await?;
    let images = product_images::for_product(&ctx.db, id).await?;
    let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
    let mut context = form_context(&ctx, &jar).await?;
    context["product"] = view::product_form(&product, &images);
    context["variants"] = json!(variants.iter().map(variant_form_json).collect::<Vec<_>>());
    format::view(&v, "admin/catalog/product_form.html", context)
}

#[debug_handler]
async fn update(
    auth: auth::JWT,
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
    multipart: Multipart,
) -> Result<Response> {
    guard::current_admin(auth, &ctx).await?;
    let existing = product_by_id(&ctx, id).await?;
    let was_published = existing.published;
    let form = read_multipart_form(multipart).await?;
    let fields = parse_product_fields(&ctx, &form, Some(id)).await?;
    let variants = parse_variants(&form)?;

    let txn = ctx.db.begin().await?;
    let mut product = existing.into_active_model();
    product.name = Set(fields.name);
    product.slug = Set(fields.slug);
    product.description = Set(fields.description);
    product.short_description = Set(fields.short_description);
    product.category_id = Set(fields.category_id);
    product.published = Set(fields.published);
    if fields.published && !was_published {
        product.published_at = Set(Some(chrono::Utc::now().into()));
    } else if !fields.published {
        product.published_at = Set(None);
    }
    product.update(&txn).await?;
    sync_variants(&txn, id, &variants).await?;
    sync_images(&ctx, &txn, id, &form.image_order, &form.images).await?;
    txn.commit().await?;

    format::redirect("/admin/catalog/products")
}

#[debug_handler]
async fn delete(
    auth: auth::JWT,
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    guard::current_admin(auth, &ctx).await?;
    product_by_id(&ctx, id).await?.delete(&ctx.db).await?;
    format::redirect("/admin/catalog/products")
}

// --- Discount profiles -----------------------------------------------------
//
// Two audiences, switched by an `?audience=` tab on the products page:
// - **personal** (default): what everyone sees.
// - **business**: what company accounts see (per-company profiles/negotiated
//   prices still layer on top; lowest price wins).
//
// Per-product absolute quick-sale prices live on each variant and are edited in
// the product form. This section is only the reusable *percentage* discount
// profiles assigned to an audience.

fn read_audience(params: &HashMap<String, String>) -> &'static str {
    match params.get("audience").map(String::as_str) {
        Some(BUSINESS) => BUSINESS,
        _ => "personal",
    }
}

fn list_redirect(audience: &str) -> Result<Response> {
    format::redirect(&format!("/admin/catalog/products?audience={audience}"))
}

/// Resolve a percentage off the regular price into a fixed sale price in cents.
fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 {
    let off = (regular_cents as f64 * percent / 100.0).round() as i64;
    regular_cents - off
}

/// Which discount value an audience tab sees on a variant.
fn current_value(variant: &product_variants::Model, audience: &str) -> Option<i64> {
    if audience == BUSINESS {
        variant.business_sale_price_cents
    } else {
        variant.sale_price_cents
    }
}

/// Set the discount column on a variant for a given audience.
fn set_value(active: &mut product_variants::ActiveModel, audience: &str, value: Option<i64>) {
    if audience == BUSINESS {
        active.business_sale_price_cents = Set(value);
    } else {
        active.sale_price_cents = Set(value);
    }
}

/// Percent off the regular price, rounded to a whole number.
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
    if regular_cents <= 0 {
        return 0;
    }
    let off = (regular_cents - sale_cents) as f64 / regular_cents as f64 * 100.0;
    off.round() as i64
}

/// Representative (first) variant for each product in `list`, in the same order,
/// dropping products with no variants. Returns the products kept alongside their
/// representative variant.
async fn representatives<'a>(
    ctx: &AppContext,
    list: &'a [products::Model],
) -> Result<(Vec<&'a products::Model>, Vec<product_variants::Model>)> {
    let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
    let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
    let mut products_kept = Vec::new();
    let mut reps = Vec::new();
    for product in list {
        if let Some(variants) = grouped.get(&product.id) {
            if let Some(first) = variants.first() {
                products_kept.push(product);
                reps.push(first.clone());
            }
        }
    }
    Ok((products_kept, reps))
}

/// Preview the effective prices that the submitted (unsaved) checkbox set would
/// produce, without persisting anything. Returns OOB `<span>`s that htmx swaps
/// into the effective-price column so the admin sees the effect before Save.
#[debug_handler]
async fn profiles_preview(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Query(params): Query<HashMap<String, String>>,
    State(ctx): State<AppContext>,
    body: String,
) -> Result<Response> {
    guard::current_admin(auth, &ctx).await?;
    let audience = read_audience(&params);

    let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
        .filter(|(k, _)| k == "profile_ids")
        .filter_map(|(_, value)| value.parse::<i32>().ok())
        .collect();

    let all_categories = categories::Entity::find()
        .order_by_asc(categories::Column::Position)
        .order_by_asc(categories::Column::Name)
        .all(&ctx.db)
        .await?;
    let list = products::Entity::find()
        .order_by_desc(products::Column::CreatedAt)
        .all(&ctx.db)
        .await?;
    let (products_kept, reps) = representatives(&ctx, &list).await?;
    let effective =
        pricing::audience_price_variants_preview(&ctx, &reps, audience, profile_ids).await?;

    let selected_category = params.get("category").map(String::as_str).unwrap_or("all");
    let filter = view::category_filter_ids(&all_categories, selected_category);

    let mut rows = Vec::new();
    for (product, priced) in products_kept.iter().zip(effective.iter()) {
        if !view::category_filter_keep(&filter, product.category_id) {
            continue;
        }
        rows.push(json!({
            "id": product.id,
            "effective_price": format_price(priced.price_cents),
            "effective_reduced": priced.is_reduced(),
            "effective_percent_off": percent_off(priced.regular_cents, priced.price_cents),
        }));
    }

    format::view(
        &v,
        "admin/catalog/_price_preview.html",
        json!({ "products": rows, "lang": current_lang(&jar) }),
    )
}

/// Replace the profiles applied to this audience with the submitted checkbox set
/// (`profile_ids`, a repeated field parsed directly from the body).
#[debug_handler]
async fn sync_profiles(
    auth: auth::JWT,
    Query(params): Query<HashMap<String, String>>,
    State(ctx): State<AppContext>,
    body: String,
) -> Result<Response> {
    guard::current_admin(auth, &ctx).await?;
    let audience = read_audience(&params);

    let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
        .filter(|(k, _)| k == "profile_ids")
        .filter_map(|(_, value)| value.parse::<i32>().ok())
        .collect();

    let txn = ctx.db.begin().await?;
    audience_discount_profiles::Entity::delete_many()
        .filter(audience_discount_profiles::Column::Audience.eq(audience))
        .exec(&txn)
        .await?;
    for profile_id in profile_ids {
        audience_discount_profiles::ActiveModel {
            audience: Set(audience.to_string()),
            discount_profile_id: Set(profile_id),
            ..Default::default()
        }
        .insert(&txn)
        .await?;
    }
    txn.commit().await?;
    list_redirect(audience)
}

// --- Per-variant discounts ---------------------------------------------------
//
// Each product is sold as one or more options (variants). A discount can be set
// on every option individually, for the active audience: personal writes the
// public `sale_price_cents`, business writes `business_sale_price_cents`. Per
// option the admin picks a fixed sale price or a percentage off the regular
// price; an empty value clears that option's discount.

/// One option row in the discount form. Carries enough to pre-fill the editor
/// and to survive a validation-error round-trip.
struct DiscountRow {
    id: i32,
    label: String,
    regular_cents: i64,
    mode: String,
    fixed: String,
    percent: String,
    has_discount: bool,
}

impl DiscountRow {
    /// Pre-fill from the discount stored for this audience.
    fn from_db(v: &product_variants::Model, audience: &str) -> Self {
        let sale = current_value(v, audience);
        DiscountRow {
            id: v.id,
            label: v.label.clone(),
            regular_cents: v.price_cents,
            mode: "fixed".to_string(),
            fixed: sale.map(format_price).unwrap_or_default(),
            percent: String::new(),
            has_discount: sale.is_some(),
        }
    }

    /// Pre-fill from the submitted values, to repaint the form after an error.
    fn from_submitted(
        v: &product_variants::Model,
        audience: &str,
        pairs: &HashMap<String, String>,
    ) -> Self {
        let get = |key: &str| {
            pairs
                .get(&format!("v[{}][{key}]", v.id))
                .map(|s| s.trim().to_string())
                .unwrap_or_default()
        };
        let mode = get("mode");
        DiscountRow {
            id: v.id,
            label: v.label.clone(),
            regular_cents: v.price_cents,
            mode: if mode == "percent" {
                mode
            } else {
                "fixed".to_string()
            },
            fixed: get("fixed"),
            percent: get("percent"),
            has_discount: current_value(v, audience).is_some(),
        }
    }

    fn to_json(&self) -> serde_json::Value {
        json!({
            "id": self.id,
            "label": self.label,
            "regular_cents": self.regular_cents,
            "regular_price": format_price(self.regular_cents),
            "mode": self.mode,
            "fixed": self.fixed,
            "percent": self.percent,
            "has_discount": self.has_discount,
        })
    }
}

/// Resolve one submitted option into the sale price to store. `Ok(None)` clears
/// the discount; `Err` is an i18n key for the validation message.
fn resolve_row(
    regular_cents: i64,
    mode: &str,
    fixed: &str,
    percent: &str,
) -> std::result::Result<Option<i64>, &'static str> {
    let sale_cents = if mode == "percent" {
        if percent.is_empty() {
            return Ok(None);
        }
        let pct = parse_percent(percent).ok_or("discount-invalid")?;
        if pct <= 0.0 || pct >= 100.0 {
            return Err("discount-percent-range");
        }
        percent_to_sale_cents(regular_cents, pct)
    } else {
        if fixed.is_empty() {
            return Ok(None);
        }
        parse_price_to_cents(fixed).map_err(|_| "discount-invalid")?
    };
    if sale_cents <= 0 {
        return Err("discount-must-be-positive");
    }
    if sale_cents >= regular_cents {
        return Err("discount-below-regular");
    }
    Ok(Some(sale_cents))
}

async fn discount_view(
    v: &TeraView,
    jar: &CookieJar,
    product: &products::Model,
    rows: &[DiscountRow],
    audience: &str,
    error: Option<&str>,
) -> Result<Response> {
    let rows_json: Vec<_> = rows.iter().map(DiscountRow::to_json).collect();
    let has_discount = rows.iter().any(|r| r.has_discount);
    format::view(
        v,
        "admin/catalog/discount_form.html",
        json!({
            "product": {
                "id": product.id,
                "name": product.name,
            },
            "rows": rows_json,
            "audience": audience,
            "has_discount": has_discount,
            "error": error.map(|e| e.to_string()),
            "lang": current_lang(jar),
        }),
    )
}

#[debug_handler]
async fn discount_show(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Path(id): Path<i32>,
    Query(params): Query<HashMap<String, String>>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    guard::current_admin(auth, &ctx).await?;
    let audience = read_audience(&params);
    let product = product_by_id(&ctx, id).await?;
    let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
    let rows: Vec<DiscountRow> = variants
        .iter()
        .map(|variant| DiscountRow::from_db(variant, audience))
        .collect();
    discount_view(&v, &jar, &product, &rows, audience, None).await
}

#[debug_handler]
async fn discount_update(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Path(id): Path<i32>,
    Query(params): Query<HashMap<String, String>>,
    State(ctx): State<AppContext>,
    body: String,
) -> Result<Response> {
    guard::current_admin(auth, &ctx).await?;
    let audience = read_audience(&params);
    let product = product_by_id(&ctx, id).await?;
    let variants = product_variants::Entity::for_product(&ctx.db, id).await?;

    let pairs: HashMap<String, String> = form_urlencoded::parse(body.as_bytes())
        .into_owned()
        .collect();

    // Resolve every option before persisting anything, so one bad row can't leave
    // the product half-discounted. On the first error, repaint with the inputs.
    let mut resolved: Vec<(product_variants::Model, Option<i64>)> = Vec::new();
    for variant in &variants {
        let row = DiscountRow::from_submitted(variant, audience, &pairs);
        match resolve_row(variant.price_cents, &row.mode, &row.fixed, &row.percent) {
            Ok(value) => resolved.push((variant.clone(), value)),
            Err(key) => {
                let rows: Vec<DiscountRow> = variants
                    .iter()
                    .map(|v| DiscountRow::from_submitted(v, audience, &pairs))
                    .collect();
                return discount_view(&v, &jar, &product, &rows, audience, Some(key)).await;
            }
        }
    }

    let txn = ctx.db.begin().await?;
    for (variant, value) in &resolved {
        let mut active = variant.clone().into_active_model();
        set_value(&mut active, audience, *value);
        active.update(&txn).await?;
    }
    txn.commit().await?;
    list_redirect(audience)
}

#[debug_handler]
async fn discount_remove(
    auth: auth::JWT,
    Path(id): Path<i32>,
    Query(params): Query<HashMap<String, String>>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    guard::current_admin(auth, &ctx).await?;
    let audience = read_audience(&params);
    let _product = product_by_id(&ctx, id).await?;
    let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
    let txn = ctx.db.begin().await?;
    for variant in &variants {
        let mut active = variant.clone().into_active_model();
        set_value(&mut active, audience, None);
        active.update(&txn).await?;
    }
    txn.commit().await?;
    list_redirect(audience)
}

pub fn routes() -> Routes {
    // Several images may be uploaded in one submission; allow a generous total
    // (per-file size is still capped at IMAGE_MAX_BYTES while reading).
    let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES * 12 + 1024 * 1024);
    Routes::new()
        .add("/admin/catalog/products", get(index))
        .add("/admin/catalog/products/new", get(new))
        .add(
            "/admin/catalog/products",
            post(create).layer(image_limit.clone()),
        )
        .add("/admin/catalog/products/profiles", post(sync_profiles))
        .add(
            "/admin/catalog/products/profiles/preview",
            post(profiles_preview),
        )
        .add("/admin/catalog/products/{id}/edit", get(edit))
        .add(
            "/admin/catalog/products/{id}",
            post(update).layer(image_limit),
        )
        .add("/admin/catalog/products/{id}/delete", post(delete))
        .add(
            "/admin/catalog/products/{id}/discount/edit",
            get(discount_show),
        )
        .add(
            "/admin/catalog/products/{id}/discount",
            post(discount_update),
        )
        .add(
            "/admin/catalog/products/{id}/discount/remove",
            post(discount_remove),
        )
}
