//! JSON shaping for storefront and catalog-admin templates.

use serde_json::{json, Value};

use crate::models::_entities::{categories, product_images, product_variants, products};
use crate::shared::currency::Currency;
use crate::shared::pricing::PricedProduct;

/// Card/list shape for a product: model fields plus the viewer's resolved price
/// for its representative (first) variant, the variant count (so the template can
/// render "from {price}" for multi-variant products), its optional primary image
/// and category. `on_sale` means "render the price as reduced" — driven by the
/// resolved price, so it covers both public sales and business deals;
/// `is_business` flags the latter. `representative` is the variant whose price is
/// shown and whose stock/sku the card exposes.
pub fn product_card(
    product: &products::Model,
    representative: &product_variants::Model,
    priced: &PricedProduct,
    variant_count: usize,
    image: Option<String>,
    category_name: Option<String>,
    cur: &Currency,
) -> Value {
    // Whole-percent discount for the card's sale badge (e.g. "−15 %"). Only
    // meaningful when the resolved price is actually reduced below the regular.
    let percent_off = if priced.is_reduced() && priced.regular_cents > priced.price_cents {
        (((priced.regular_cents - priced.price_cents) as f64 / priced.regular_cents as f64) * 100.0)
            .round() as i64
    } else {
        0
    };
    json!({
        "id": product.id,
        "variant_id": representative.id,
        "name": product.name,
        "slug": product.slug,
        "description": product.description,
        "short_description": product.short_description,
        "price": cur.format(priced.price_cents),
        "on_sale": priced.is_reduced(),
        "percent_off": percent_off,
        "is_business": priced.is_business,
        "regular_price": cur.format(priced.regular_cents),
        "sku": representative.sku,
        "stock": representative.stock,
        "tracked": representative.tracked(),
        "in_stock": representative.in_stock(),
        "variant_count": variant_count,
        "has_options": variant_count > 1,
        "published": product.published,
        "image": image,
        "category_name": category_name,
    })
}

/// One priced variant row for the product detail page's option picker.
pub fn variant_option(
    variant: &product_variants::Model,
    priced: &PricedProduct,
    cur: &Currency,
) -> Value {
    json!({
        "id": variant.id,
        "label": variant.label,
        "sku": variant.sku,
        "stock": variant.stock,
        "tracked": variant.tracked(),
        "in_stock": variant.in_stock(),
        "price": cur.format(priced.price_cents),
        "on_sale": priced.is_reduced(),
        "regular_price": cur.format(priced.regular_cents),
        "is_business": priced.is_business,
    })
}

/// Shape used to pre-fill the admin product form (exposes `category_id` rather
/// than a resolved name, and the current images in display order — first is the
/// main one). Variants are supplied separately by the controller.
pub fn product_form(product: &products::Model, images: &[product_images::Model]) -> Value {
    json!({
        "id": product.id,
        "name": product.name,
        "slug": product.slug,
        "description": product.description,
        "short_description": product.short_description,
        "published": product.published,
        "category_id": product.category_id,
        "images": images
            .iter()
            .map(|im| json!({ "id": im.id, "image_id": im.image_id }))
            .collect::<Vec<_>>(),
    })
}

/// Two-level grouping for the storefront sidebar menu: each top-level category
/// as `{ name, slug, children: [{ name, slug }] }`, with its direct
/// subcategories nested under `children` (empty when the category has none).
/// Siblings are ordered by position then name on both levels.
pub fn sidebar_groups(categories: &[categories::Model]) -> Vec<Value> {
    let mut top: Vec<&categories::Model> = categories
        .iter()
        .filter(|c| c.parent_id.is_none())
        .collect();
    top.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));

    top.into_iter()
        .map(|category| {
            let children: Vec<Value> = crate::models::categories::children_of(categories, category.id)
                .into_iter()
                .map(|child| json!({ "name": child.name, "slug": child.slug }))
                .collect();
            json!({ "name": category.name, "slug": category.slug, "children": children })
        })
        .collect()
}

/// Admin category-filter tree for product listings: like [`sidebar_groups`] but
/// each node carries its `id` (links filter by `?category=<id>`) and a `count`
/// of matching products — the node's own products plus every descendant's, so a
/// parent's count covers its whole subtree. `category_ids` is each product's
/// `category_id` (`None` = uncategorized), taken over the full unfiltered list.
pub fn admin_category_groups(
    categories: &[categories::Model],
    category_ids: &[Option<i32>],
) -> Vec<Value> {
    use std::collections::HashMap;

    let mut direct: HashMap<i32, usize> = HashMap::new();
    for id in category_ids.iter().flatten() {
        *direct.entry(*id).or_default() += 1;
    }
    let subtree_count = |id: i32| -> usize {
        let mut n = direct.get(&id).copied().unwrap_or(0);
        for d in crate::models::categories::descendant_ids(categories, id) {
            n += direct.get(&d).copied().unwrap_or(0);
        }
        n
    };

    let mut top: Vec<&categories::Model> = categories
        .iter()
        .filter(|c| c.parent_id.is_none())
        .collect();
    top.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));

    top.into_iter()
        .map(|category| {
            let children: Vec<Value> = crate::models::categories::children_of(categories, category.id)
                .into_iter()
                .map(|child| {
                    json!({ "id": child.id, "name": child.name, "count": subtree_count(child.id) })
                })
                .collect();
            json!({
                "id": category.id,
                "name": category.name,
                "count": subtree_count(category.id),
                "children": children,
            })
        })
        .collect()
}

/// Resolve the `?category=` filter param against the category forest into the
/// set of `category_id`s a product may have to be shown. Returns:
/// - `None` for "all" (no filtering) or an unknown value,
/// - `Some(empty set)` for "none" (uncategorized — match products with no
///   category; callers treat an empty set as "uncategorized only"),
/// - `Some({id} ∪ descendants)` for a numeric category id.
pub fn category_filter_ids(
    categories: &[categories::Model],
    selected: &str,
) -> Option<std::collections::HashSet<i32>> {
    match selected {
        "all" => None,
        "none" => Some(std::collections::HashSet::new()),
        s => s.parse::<i32>().ok().map(|id| {
            let mut set = crate::models::categories::descendant_ids(categories, id);
            set.insert(id);
            set
        }),
    }
}

/// Whether a product with `category_id` passes the filter from
/// [`category_filter_ids`]: `None` keeps everything, an empty set keeps only
/// uncategorized products, a non-empty set keeps products in those categories.
pub fn category_filter_keep(
    filter: &Option<std::collections::HashSet<i32>>,
    category_id: Option<i32>,
) -> bool {
    match filter {
        None => true,
        Some(set) if set.is_empty() => category_id.is_none(),
        Some(set) => category_id.is_some_and(|id| set.contains(&id)),
    }
}
