//! Public storefront: product listings, product detail, category pages and the
//! lazily-loaded category sidebar.

use std::collections::HashMap;

use axum::extract::Query;
use axum::http::HeaderMap;
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
use serde_json::json;

use crate::{
    controllers::i18n::current_lang,
    shared::{
        currency::{self, Currency},
        guard,
        money::parse_price_to_cents,
        pricing,
    },
    models::{categories, product_images, product_variants, products, users},
    views::shop as view,
};

/// Default results per page in the storefront listing/search.
const PER_PAGE: usize = 24;
/// Allowed per-page choices offered in the toolbar; any other value falls back
/// to [`PER_PAGE`].
const PER_PAGE_OPTIONS: [usize; 3] = [24, 48, 96];

/// Resolve the requested per-page count to one of [`PER_PAGE_OPTIONS`],
/// defaulting to [`PER_PAGE`].
fn resolve_per_page(params: &SearchParams) -> usize {
    params
        .per_page
        .map(|p| p as usize)
        .filter(|p| PER_PAGE_OPTIONS.contains(p))
        .unwrap_or(PER_PAGE)
}
/// Hard cap on candidates a single text search considers before faceting; well
/// above any realistic page of results for this catalog.
const SEARCH_CAP: u64 = 1000;

/// All storefront listing controls: free-text query, category, price band,
/// stock, sort and page. Everything is optional so `/shop` and `/search` share
/// one shape.
#[derive(Debug, Default, serde::Deserialize)]
struct SearchParams {
    q: Option<String>,
    category: Option<String>,
    min_price: Option<String>,
    max_price: Option<String>,
    in_stock: Option<String>,
    sort: Option<String>,
    page: Option<u32>,
    per_page: Option<u32>,
}

/// A candidate product with everything the listing needs to filter, sort and
/// render it: its representative (first) variant, the resolved price for the
/// viewer, stock, variant count and original search rank (for relevance order).
struct Candidate {
    product: products::Model,
    rep: product_variants::Model,
    priced: pricing::PricedProduct,
    in_stock: bool,
    count: usize,
    rank: usize,
}

/// Whether a checkbox-style param is on (present and not an explicit "off"/"0").
fn is_on(v: &Option<String>) -> bool {
    matches!(v.as_deref(), Some(s) if !s.is_empty() && s != "0" && s != "false" && s != "off")
}

/// Rebuild the query string from `params` minus `page`, so pagination links can
/// preserve the active query + filters + sort.
fn query_base(params: &SearchParams) -> String {
    let mut ser = form_urlencoded::Serializer::new(String::new());
    if let Some(q) = params.q.as_deref().filter(|s| !s.is_empty()) {
        ser.append_pair("q", q);
    }
    if let Some(c) = params.category.as_deref().filter(|s| !s.is_empty() && *s != "all") {
        ser.append_pair("category", c);
    }
    if let Some(p) = params.min_price.as_deref().filter(|s| !s.is_empty()) {
        ser.append_pair("min_price", p);
    }
    if let Some(p) = params.max_price.as_deref().filter(|s| !s.is_empty()) {
        ser.append_pair("max_price", p);
    }
    if is_on(&params.in_stock) {
        ser.append_pair("in_stock", "1");
    }
    if let Some(s) = params.sort.as_deref().filter(|s| !s.is_empty()) {
        ser.append_pair("sort", s);
    }
    if let Some(p) = params.per_page.filter(|p| *p as usize != PER_PAGE) {
        ser.append_pair("per_page", &p.to_string());
    }
    ser.finish()
}

/// Run the full faceted listing pipeline for `params` and shape the template
/// context (results page + facet data + pagination). Reused by `/shop` and
/// `/search`; the caller adds chrome and picks the template.
async fn run_search(
    ctx: &AppContext,
    user: Option<&users::Model>,
    params: &SearchParams,
    cur: &Currency,
) -> Result<serde_json::Value> {
    let q = params.q.clone().unwrap_or_default();
    let q_trim = q.trim().to_string();

    // 1. Base candidates: ranked search hits, or the full published listing.
    let base: Vec<products::Model> = if q_trim.is_empty() {
        products::Entity::find()
            .filter(products::Column::Published.eq(true))
            .order_by_desc(products::Column::PublishedAt)
            .all(&ctx.db)
            .await?
    } else {
        products::Entity::search(&ctx.db, &q_trim, SEARCH_CAP, true).await?
    };

    // 2. Attach representative variant + resolved price to each (drop products
    //    with no purchasable variant).
    let ids: Vec<i32> = base.iter().map(|p| p.id).collect();
    let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
    let mut staged: Vec<(products::Model, product_variants::Model, usize, usize)> = Vec::new();
    for (rank, product) in base.into_iter().enumerate() {
        if let Some(vs) = grouped.get(&product.id) {
            if let Some(rep) = vs.first() {
                staged.push((product, rep.clone(), vs.len(), rank));
            }
        }
    }
    let reps: Vec<product_variants::Model> = staged.iter().map(|(_, r, _, _)| r.clone()).collect();
    let priced = pricing::price_variants(ctx, &reps, user).await?;
    let mut items: Vec<Candidate> = staged
        .into_iter()
        .zip(priced.iter())
        .map(|((product, rep, count, rank), p)| Candidate {
            in_stock: rep.in_stock(),
            product,
            rep,
            priced: *p,
            count,
            rank,
        })
        .collect();

    // Price band bounds across all matches, to hint the filter UI.
    let price_floor = items.iter().map(|i| i.priced.price_cents).min().unwrap_or(0);
    let price_ceil = items.iter().map(|i| i.priced.price_cents).max().unwrap_or(0);

    // 3. Non-category filters: price band + in-stock. The typed bounds are in
    //    the buyer's display currency; convert them back to EUR cents to compare
    //    against the (EUR) resolved prices.
    let min_c = params
        .min_price
        .as_deref()
        .and_then(|s| parse_price_to_cents(s).ok())
        .map(|c| cur.to_eur_cents(c));
    let max_c = params
        .max_price
        .as_deref()
        .and_then(|s| parse_price_to_cents(s).ok())
        .map(|c| cur.to_eur_cents(c));
    let in_stock_only = is_on(&params.in_stock);
    items.retain(|i| {
        min_c.is_none_or(|m| i.priced.price_cents >= m)
            && max_c.is_none_or(|m| i.priced.price_cents <= m)
            && (!in_stock_only || i.in_stock)
    });

    // 4. Category facets: counts computed over the price/stock-filtered set
    //    (i.e. before applying the category choice itself).
    let all_categories = categories::published(ctx).await?;
    let cat_ids: Vec<Option<i32>> = items.iter().map(|i| i.product.category_id).collect();
    let category_groups = view::admin_category_groups(&all_categories, &cat_ids);
    let uncategorized_count = cat_ids.iter().filter(|c| c.is_none()).count();
    let category_name: HashMap<i32, String> =
        all_categories.iter().map(|c| (c.id, c.name.clone())).collect();

    // 5. Apply the category filter.
    let selected_category = params
        .category
        .clone()
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "all".to_string());
    let filter = view::category_filter_ids(&all_categories, &selected_category);
    items.retain(|i| view::category_filter_keep(&filter, i.product.category_id));

    // 6. Sort. Newest-first is the default; relevance (the ranked search order)
    //    is available explicitly via the sort control. When a search runs, the
    //    default "newest" becomes "relevance" (a query implies relevance matters
    //    most); any explicitly chosen non-newest sort is left untouched.
    let mut sort = params
        .sort
        .clone()
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "newest".to_string());
    if !q_trim.is_empty() && sort == "newest" {
        sort = "relevance".to_string();
    }
    match sort.as_str() {
        "price_asc" => items.sort_by(|a, b| a.priced.price_cents.cmp(&b.priced.price_cents)),
        "price_desc" => items.sort_by(|a, b| b.priced.price_cents.cmp(&a.priced.price_cents)),
        "name_asc" => items.sort_by(|a, b| {
            a.product.name.to_lowercase().cmp(&b.product.name.to_lowercase())
        }),
        "name_desc" => items.sort_by(|a, b| {
            b.product.name.to_lowercase().cmp(&a.product.name.to_lowercase())
        }),
        "newest" => items.sort_by(|a, b| b.product.published_at.cmp(&a.product.published_at)),
        // "relevance" and anything unknown: original search rank.
        _ => items.sort_by_key(|i| i.rank),
    }

    // 7. Paginate.
    let per_page = resolve_per_page(params);
    let total = items.len();
    let pages = total.div_ceil(per_page).max(1);
    let page = params.page.unwrap_or(1).clamp(1, pages as u32);
    let start = (page as usize - 1) * per_page;

    // 8. Render only the current page's cards (images fetched per row).
    let mut rows = Vec::new();
    for item in items.iter().skip(start).take(per_page) {
        let image = product_images::first_for(ctx, item.product.id).await?;
        let cat_name = item.product.category_id.and_then(|id| category_name.get(&id).cloned());
        rows.push(view::product_card(
            &item.product,
            &item.rep,
            &item.priced,
            item.count,
            image,
            cat_name,
            cur,
        ));
    }

    Ok(json!({
        "products": rows,
        "query": q,
        "category_groups": category_groups,
        "selected_category": selected_category,
        // Numeric form so the <select> can mark the active option (Tera can't
        // compare a string param against a numeric category id).
        "selected_category_id": selected_category.parse::<i32>().unwrap_or(-1),
        // Display name of the active category, so the search bar can show that
        // the query is scoped to it. `None` for "all"/"none" (the template maps
        // "none" to the localized "uncategorized" label itself).
        "selected_category_name": selected_category
            .parse::<i32>()
            .ok()
            .and_then(|id| category_name.get(&id).cloned()),
        "uncategorized_count": uncategorized_count,
        "sort": sort,
        "per_page": per_page,
        "per_page_options": PER_PAGE_OPTIONS,
        "in_stock": in_stock_only,
        "min_price": params.min_price.clone().unwrap_or_default(),
        "max_price": params.max_price.clone().unwrap_or_default(),
        "price_floor": cur.format(price_floor),
        "price_ceil": cur.format(price_ceil),
        "currency_symbol": cur.symbol,
        "total": total,
        "page": page,
        "pages": pages,
        "has_prev": page > 1,
        "has_next": (page as usize) < pages,
        "prev_page": page.saturating_sub(1).max(1),
        "next_page": page + 1,
        "query_base": query_base(params),
    }))
}

/// Shape a list of products into card rows for `user` (None = public). Each card
/// shows the resolved price of the product's representative (first) variant; the
/// `variant_count` lets the template render "from {price}" for multi-variant
/// products. Products with no variants are skipped (not purchasable).
async fn product_rows(
    ctx: &AppContext,
    user: Option<&users::Model>,
    list: Vec<products::Model>,
    cur: &Currency,
) -> Result<Vec<serde_json::Value>> {
    let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
    let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;

    // Representative (first) variant per product, in list order, dropping any
    // product that has no variants.
    let mut entries: Vec<(&products::Model, product_variants::Model, usize)> = Vec::new();
    for product in &list {
        if let Some(variants) = grouped.get(&product.id) {
            if let Some(first) = variants.first() {
                entries.push((product, first.clone(), variants.len()));
            }
        }
    }

    let reps: Vec<product_variants::Model> = entries.iter().map(|(_, v, _)| v.clone()).collect();
    let priced = pricing::price_variants(ctx, &reps, user).await?;

    let mut rows = Vec::with_capacity(entries.len());
    for ((product, rep, count), priced) in entries.iter().zip(priced.iter()) {
        let image = product_images::first_for(ctx, product.id).await?;
        rows.push(view::product_card(product, rep, priced, *count, image, None, cur));
    }
    Ok(rows)
}

/// Latest published products (with primary image), shaped for templates. Reused
/// by the home-page landing grid.
pub(crate) async fn featured_products(
    ctx: &AppContext,
    user: Option<&users::Model>,
    limit: u64,
    cur: &Currency,
) -> Result<Vec<serde_json::Value>> {
    let list = products::Entity::find()
        .filter(products::Column::Published.eq(true))
        .order_by_desc(products::Column::PublishedAt)
        .limit(limit)
        .all(&ctx.db)
        .await?;
    product_rows(ctx, user, list, cur).await
}

/// The site-wide category sidebar, loaded lazily via htmx by the base layout so
/// every page gets it without each handler having to supply category data.
#[debug_handler]
async fn category_sidebar(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let published = categories::published(&ctx).await?;
    format::view(
        &v,
        "shop/_sidebar.html",
        json!({
            "category_groups": view::sidebar_groups(&published),
            "lang": current_lang(&jar),
        }),
    )
}

/// Fold the page chrome (login state, names) and language into a `run_search`
/// context so the full page can render the layout.
fn add_chrome(ctx_value: &mut serde_json::Value, c: &guard::Chrome, lang: &str) {
    if let Some(map) = ctx_value.as_object_mut() {
        map.insert("logged_in_admin".into(), json!(c.logged_in_admin));
        map.insert("logged_in_customer".into(), json!(c.logged_in_customer));
        map.insert("customer_name".into(), json!(c.customer_name));
        map.insert("customer_account_type".into(), json!(c.customer_account_type));
        map.insert("customer_avatar".into(), json!(c.customer_avatar));
        map.insert("lang".into(), json!(lang));
    }
}

#[debug_handler]
async fn index(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let user = guard::current_user(&ctx, &jar).await;
    let cur = currency::resolve(&ctx, &jar).await;
    let mut context = run_search(&ctx, user.as_ref(), &SearchParams::default(), &cur).await?;
    let c = guard::chrome_from(&ctx, user.as_ref());
    add_chrome(&mut context, &c, &current_lang(&jar));
    format::view(&v, "shop/index.html", context)
}

/// Storefront search + faceted browse. Combines the hybrid full-text/fuzzy query
/// ([`products::Entity::search`]) with category, price-band, in-stock and sort
/// filters, ranked and paginated by [`run_search`]. A blank query falls back to
/// the full published listing, so the same endpoint powers both "browse" and
/// "search". Targeted htmx requests from the listing toolbar/pagination get just
/// the results fragment (for live updates); direct navigation, no-JS, and boosted
/// navigations (e.g. submitting the header search box, which hx-boost turns into
/// an AJAX nav) render the whole eshop page.
#[debug_handler]
async fn search(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    headers: HeaderMap,
    Query(params): Query<SearchParams>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let user = guard::current_user(&ctx, &jar).await;
    let cur = currency::resolve(&ctx, &jar).await;
    let mut context = run_search(&ctx, user.as_ref(), &params, &cur).await?;
    let lang = current_lang(&jar);

    // A boosted request (the header search form, links) replaces the whole body,
    // so it needs the full page — only the toolbar's own targeted hx-get requests
    // (HX-Request without HX-Boosted) want the bare results fragment.
    let fragment = headers.contains_key("HX-Request") && !headers.contains_key("HX-Boosted");
    if fragment {
        if let Some(map) = context.as_object_mut() {
            map.insert("lang".into(), json!(lang));
            // Lets _results.html out-of-band swap the toolbar's Sort dropdown
            // (which lives outside the swapped region) to match the ordering.
            map.insert("is_fragment".into(), json!(true));
        }
        return format::view(&v, "shop/_results.html", context);
    }

    let c = guard::chrome_from(&ctx, user.as_ref());
    add_chrome(&mut context, &c, &lang);
    format::view(&v, "shop/index.html", context)
}

#[debug_handler]
async fn show(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Path(slug): Path<String>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let product = products::Entity::find()
        .filter(products::Column::Slug.eq(slug))
        .filter(products::Column::Published.eq(true))
        .one(&ctx.db)
        .await?
        .ok_or_else(|| Error::NotFound)?;

    let mut active = product.clone().into_active_model();
    active.view_count = Set(product.view_count + 1);
    let product = active.update(&ctx.db).await?;

    let images = product_images::Entity::find()
        .filter(product_images::Column::ProductId.eq(product.id))
        .order_by_asc(product_images::Column::Position)
        .all(&ctx.db)
        .await?;
    let category = match product.category_id {
        Some(id) => categories::Entity::find_by_id(id).one(&ctx.db).await?,
        None => None,
    };

    let user = guard::current_user(&ctx, &jar).await;
    let cur = currency::resolve(&ctx, &jar).await;
    let variants = product_variants::Entity::for_product(&ctx.db, product.id).await?;
    let variant_prices = pricing::price_variants(&ctx, &variants, user.as_ref()).await?;
    let options: Vec<serde_json::Value> = variants
        .iter()
        .zip(variant_prices.iter())
        .map(|(variant, priced)| view::variant_option(variant, priced, &cur))
        .collect();
    // The card header uses the representative (first) variant for its headline
    // price; the picker below lets the customer switch.
    let representative = variants.first();
    let priced = variant_prices.first().copied();
    let card = match (representative, priced) {
        (Some(rep), Some(priced)) => view::product_card(
            &product,
            rep,
            &priced,
            variants.len(),
            None,
            category.as_ref().map(|c| c.name.clone()),
            &cur,
        ),
        // A product with no variants isn't purchasable; show it without a price.
        _ => serde_json::json!({
            "id": product.id,
            "name": product.name,
            "slug": product.slug,
            "description": product.description,
            "short_description": product.short_description,
            "variant_count": 0,
            "has_options": false,
        }),
    };
    let c = guard::chrome_from(&ctx, user.as_ref());
    format::view(
        &v,
        "shop/show.html",
        json!({
            "product": card,
            "variants": options,
            "images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(),
            "category": category,
            "logged_in_admin": c.logged_in_admin,
            "logged_in_customer": c.logged_in_customer,
            "customer_name": c.customer_name,
            "customer_account_type": c.customer_account_type,
            "customer_avatar": c.customer_avatar,
            "currency_symbol": cur.symbol,
            "lang": current_lang(&jar),
        }),
    )
}

/// Category page: the same faceted search as the shop, but with this category
/// preselected as the default filter (plus breadcrumbs and subcategory chips).
/// Any other filters/sort/query on the URL are honoured; the category itself is
/// always forced to this page's category. Interacting with the toolbar navigates
/// to `/search` (the category stays selected there too).
#[debug_handler]
async fn category(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Path(slug): Path<String>,
    Query(params): Query<SearchParams>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let published = categories::published(&ctx).await?;
    let category = published
        .iter()
        .find(|c| c.slug == slug)
        .cloned()
        .ok_or_else(|| Error::NotFound)?;

    let breadcrumbs = categories::ancestors(&published, category.parent_id);
    let children = categories::children_of(&published, category.id);

    // Force the category filter to this page's category, keeping any other params.
    let params = SearchParams {
        category: Some(category.id.to_string()),
        ..params
    };

    let user = guard::current_user(&ctx, &jar).await;
    let cur = currency::resolve(&ctx, &jar).await;
    let mut context = run_search(&ctx, user.as_ref(), &params, &cur).await?;
    if let Some(map) = context.as_object_mut() {
        map.insert("category".into(), serde_json::to_value(&category)?);
        map.insert("breadcrumbs".into(), serde_json::to_value(&breadcrumbs)?);
        map.insert("children".into(), serde_json::to_value(&children)?);
    }
    let c = guard::chrome_from(&ctx, user.as_ref());
    add_chrome(&mut context, &c, &current_lang(&jar));
    format::view(&v, "shop/category.html", context)
}

pub fn routes() -> Routes {
    Routes::new()
        .add("/shop", get(index))
        // Top-level path (not /shop/search) so it never collides with the
        // /shop/{slug} product route.
        .add("/search", get(search))
        .add("/shop/{slug}", get(show))
        .add("/category/{slug}", get(category))
        .add("/partials/categories", get(category_sidebar))
}
