use crate::{
    controllers::i18n::current_lang,
    models::{account_cart_items, product_variants, products, users},
    shared::{
        currency::{self, Currency},
        guard, pricing,
    },
};
use axum::{
    http::{HeaderMap, StatusCode},
    response::Redirect,
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Deserialize;
use serde_json::json;
use time::Duration as TimeDuration;

pub(crate) const CART_COOKIE: &str = "cart";
const CART_MAX_AGE_DAYS: i64 = 30;

#[derive(Debug, Deserialize)]
struct AddForm {
    variant_id: i32,
    quantity: Option<i32>,
}

#[derive(Debug, Deserialize)]
struct UpdateForm {
    variant_id: i32,
    quantity: i32,
}

#[derive(Debug, Deserialize)]
struct RemoveForm {
    variant_id: i32,
}

/// Parse the `cart` cookie ("id:qty,id:qty") into `(variant_id, quantity)`
/// pairs, silently dropping malformed or non-positive entries.
pub(crate) fn parse_cart(jar: &CookieJar) -> Vec<(i32, i32)> {
    let Some(cookie) = jar.get(CART_COOKIE) else {
        return Vec::new();
    };
    cookie
        .value()
        .split(',')
        .filter_map(|entry| {
            let (id, qty) = entry.split_once(':')?;
            let id = id.trim().parse::<i32>().ok()?;
            let qty = qty.trim().parse::<i32>().ok()?;
            (qty > 0).then_some((id, qty))
        })
        .collect()
}

fn serialize_cart(items: &[(i32, i32)]) -> String {
    items
        .iter()
        .map(|(id, qty)| format!("{id}:{qty}"))
        .collect::<Vec<_>>()
        .join(",")
}

fn cart_cookie(value: String) -> Cookie<'static> {
    Cookie::build((CART_COOKIE, value))
        .path("/")
        .same_site(SameSite::Lax)
        .max_age(TimeDuration::days(CART_MAX_AGE_DAYS))
        .build()
}

pub(crate) fn cleared_cart_cookie() -> Cookie<'static> {
    Cookie::build((CART_COOKIE, ""))
        .path("/")
        .same_site(SameSite::Lax)
        .max_age(TimeDuration::seconds(0))
        .build()
}

fn normalize_items(items: Vec<(i32, i32)>) -> Vec<(i32, i32)> {
    let mut normalized: Vec<(i32, i32)> = Vec::new();
    for (id, qty) in items.into_iter().filter(|(_, qty)| *qty > 0) {
        if let Some(existing) = normalized.iter_mut().find(|(existing_id, _)| *existing_id == id) {
            existing.1 += qty;
        } else {
            normalized.push((id, qty));
        }
    }
    normalized
}

async fn stored_cart(
    ctx: &AppContext,
    user: Option<&users::Model>,
    jar: &CookieJar,
) -> Result<Vec<(i32, i32)>> {
    match user {
        Some(user) => Ok(account_cart_items::Model::find_for_user(&ctx.db, user.id).await?),
        None => Ok(normalize_items(parse_cart(jar))),
    }
}

async fn persist_cart(
    ctx: &AppContext,
    jar: CookieJar,
    user: Option<&users::Model>,
    items: &[(i32, i32)],
) -> Result<CookieJar> {
    let items = normalize_items(items.to_vec());
    if let Some(user) = user {
        account_cart_items::Model::replace_for_user(&ctx.db, user.id, &items).await?;
    }
    Ok(jar.add(cart_cookie(serialize_cart(&items))))
}

pub(crate) async fn claim_guest_cart(
    ctx: &AppContext,
    jar: &CookieJar,
    user_id: i32,
) -> Result<()> {
    let items = normalize_items(parse_cart(jar));
    if !items.is_empty() {
        account_cart_items::Model::replace_for_user(&ctx.db, user_id, &items).await?;
    }
    Ok(())
}

pub(crate) async fn cart_cookie_for_user(
    ctx: &AppContext,
    user_id: i32,
) -> Result<Cookie<'static>> {
    let items = account_cart_items::Model::find_for_user(&ctx.db, user_id).await?;
    Ok(cart_cookie(serialize_cart(&items)))
}

pub(crate) async fn clear_account_cart(ctx: &AppContext, user_id: i32) -> Result<()> {
    account_cart_items::Model::replace_for_user(&ctx.db, user_id, &[]).await?;
    Ok(())
}

/// Look up a variant whose product is published, returning the variant together
/// with its parent product (for name/slug).
async fn published_variant(
    ctx: &AppContext,
    variant_id: i32,
) -> Result<Option<(product_variants::Model, products::Model)>> {
    let Some(variant) = product_variants::Entity::find_by_id(variant_id)
        .one(&ctx.db)
        .await?
    else {
        return Ok(None);
    };
    let product = products::Entity::find_by_id(variant.product_id)
        .filter(products::Column::Published.eq(true))
        .one(&ctx.db)
        .await?;
    Ok(product.map(|p| (variant, p)))
}

#[debug_handler]
async fn add(
    jar: CookieJar,
    State(ctx): State<AppContext>,
    headers: HeaderMap,
    Form(form): Form<AddForm>,
) -> Result<Response> {
    let Some((variant, _product)) = published_variant(&ctx, form.variant_id).await? else {
        return Err(Error::NotFound);
    };

    let user = guard::current_user(&ctx, &jar).await;
    let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
    let add_qty = form.quantity.unwrap_or(1).max(1);
    // Clamp to on-hand stock when tracked and in stock; an out-of-stock (stock 0)
    // item is still added at the requested quantity so it isn't silently dropped —
    // it shows in the cart as unavailable until restocked or removed.
    let new_qty = |desired: i32| match variant.stock {
        Some(0) => desired.max(1),
        Some(s) => desired.clamp(1, s),
        None => desired.max(1),
    };
    if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
        entry.1 = new_qty(entry.1 + add_qty);
    } else {
        items.push((variant.id, new_qty(add_qty)));
    }

    let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;

    // Adding to the cart should never navigate away: htmx requests get an empty
    // 204 (the header cart badge updates client-side), and a no-JS submit goes
    // back to the page the customer was on rather than to the basket.
    if headers.contains_key("HX-Request") {
        Ok((jar, StatusCode::NO_CONTENT).into_response())
    } else {
        let back = headers
            .get("referer")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("/shop")
            .to_string();
        Ok((jar, Redirect::to(&back)).into_response())
    }
}

#[debug_handler]
async fn update(
    jar: CookieJar,
    State(ctx): State<AppContext>,
    ViewEngine(v): ViewEngine<TeraView>,
    headers: HeaderMap,
    Form(form): Form<UpdateForm>,
) -> Result<Response> {
    // Clamp the requested quantity to what's available (no cap for untracked
    // variants); a removed variant clamps to 0 and drops out below.
    let clamped = match published_variant(&ctx, form.variant_id).await? {
        Some((variant, _)) => variant.cap(form.quantity),
        None => 0,
    };

    let user = guard::current_user(&ctx, &jar).await;
    let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
    if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
        entry.1 = clamped;
    }
    items.retain(|(_, qty)| *qty > 0);

    let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
    cart_response(&ctx, &v, jar, &headers).await
}

#[debug_handler]
async fn remove(
    jar: CookieJar,
    State(ctx): State<AppContext>,
    ViewEngine(v): ViewEngine<TeraView>,
    headers: HeaderMap,
    Form(form): Form<RemoveForm>,
) -> Result<Response> {
    let user = guard::current_user(&ctx, &jar).await;
    let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
    items.retain(|(id, _)| *id != form.variant_id);

    let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
    cart_response(&ctx, &v, jar, &headers).await
}

/// Response after a cart mutation: for an htmx request, just the `#cart-body`
/// fragment (so the page never fully reloads); otherwise a redirect back to
/// `/cart` for no-JS fallback. `jar` must already hold the updated cart cookie.
async fn cart_response(
    ctx: &AppContext,
    v: &TeraView,
    jar: CookieJar,
    headers: &HeaderMap,
) -> Result<Response> {
    if !headers.contains_key("HX-Request") {
        return Ok((jar, Redirect::to("/cart")).into_response());
    }

    let cur = currency::resolve(ctx, &jar).await;
    let cart = resolve_cart(ctx, &jar, &cur).await?;
    // Persist the re-validated cookie (keeps out-of-stock lines, drops gone ones).
    let user = guard::current_user(ctx, &jar).await;
    let jar = persist_cart(ctx, jar, user.as_ref(), &cart.stored).await?;
    let response = format::view(
        v,
        "shop/_cart_body.html",
        json!({
            "items": cart.lines,
            "total": cur.format(cart.total),
            "currency_symbol": cur.symbol,
            "has_unavailable": cart.has_unavailable,
            "lang": current_lang(&jar),
        }),
    )?;
    Ok((jar, response).into_response())
}

/// Resolve the active cart into priced line items, dropping anything that is no
/// longer purchasable and clamping quantities to current stock. Guests resolve
/// from the cookie; authenticated users resolve from their account cart.
/// The outcome of resolving the active cart. `lines` is for display and includes
/// out-of-stock items (flagged `available: false`); `purchasable` is the in-stock
/// subset used for the `total` and for checkout; `stored` is the full set written
/// back to the cart (so out-of-stock items are *kept*, not silently dropped);
/// `has_unavailable` is set when any line is currently out of stock.
pub(crate) struct ResolvedCart {
    pub lines: Vec<serde_json::Value>,
    pub purchasable: Vec<(i32, i32)>,
    pub stored: Vec<(i32, i32)>,
    pub total: i64,
    pub has_unavailable: bool,
}

pub(crate) async fn resolve_cart(
    ctx: &AppContext,
    jar: &CookieJar,
    cur: &Currency,
) -> Result<ResolvedCart> {
    // Resolve the cart entries to their (published) products, then price them all
    // for the current viewer in one batch (the price depends on who's logged in).
    let user = guard::current_user(ctx, jar).await;
    let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new();
    for (id, qty) in stored_cart(ctx, user.as_ref(), jar).await? {
        let Some((variant, product)) = published_variant(ctx, id).await? else {
            continue;
        };
        if qty <= 0 {
            continue;
        }
        items.push((variant, product, qty));
    }
    let variants_only: Vec<product_variants::Model> =
        items.iter().map(|(v, _, _)| v.clone()).collect();
    let priced = pricing::price_variants(ctx, &variants_only, user.as_ref()).await?;

    let mut lines = Vec::new();
    let mut purchasable = Vec::new();
    let mut stored = Vec::new();
    let mut total: i64 = 0;
    let mut has_unavailable = false;
    for ((variant, product, qty), priced) in items.iter().zip(priced.iter()) {
        // Out-of-stock (tracked stock of 0) items are kept in the cart, shown as
        // unavailable and excluded from the total/checkout, instead of being
        // silently removed. In-stock quantities are clamped to what's on hand.
        let out_of_stock = matches!(variant.stock, Some(0));
        let store_qty = if out_of_stock { *qty } else { variant.cap(*qty) };
        if store_qty <= 0 {
            continue;
        }
        stored.push((variant.id, store_qty));

        let unit_price = priced.price_cents;
        let line_total = unit_price * i64::from(store_qty);
        if out_of_stock {
            has_unavailable = true;
        } else {
            total += line_total;
            purchasable.push((variant.id, store_qty));
        }
        lines.push(json!({
            "id": variant.id,
            "name": product.name,
            "variant_label": variant.label,
            "slug": product.slug,
            "price": cur.format(unit_price),
            "regular_price": cur.format(priced.regular_cents),
            "on_sale": priced.is_reduced(),
            "quantity": store_qty,
            "stock": variant.stock,
            "available": !out_of_stock,
            "out_of_stock": out_of_stock,
            "line_total": cur.format(line_total),
        }));
    }

    if let Some(user) = user.as_ref() {
        account_cart_items::Model::replace_for_user(&ctx.db, user.id, &stored).await?;
    }

    Ok(ResolvedCart {
        lines,
        purchasable,
        stored,
        total,
        has_unavailable,
    })
}

#[debug_handler]
async fn show(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let cur = currency::resolve(&ctx, &jar).await;
    let cart = resolve_cart(&ctx, &jar, &cur).await?;

    let c = guard::chrome(&ctx, &jar).await;
    let response = format::view(
        &v,
        "shop/cart.html",
        json!({
            "items": cart.lines,
            "total": cur.format(cart.total),
            "currency_symbol": cur.symbol,
            "has_unavailable": cart.has_unavailable,
            "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,
            "lang": current_lang(&jar),
        }),
    )?;

    let user = guard::current_user(&ctx, &jar).await;
    let jar = persist_cart(&ctx, jar, user.as_ref(), &cart.stored).await?;
    Ok((jar, response).into_response())
}

/// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from
/// the header; returns just the `shop/_cart_preview.html` fragment.
#[debug_handler]
async fn preview(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let cur = currency::resolve(&ctx, &jar).await;
    let cart = resolve_cart(&ctx, &jar, &cur).await?;
    let response = format::view(
        &v,
        "shop/_cart_preview.html",
        json!({
            "items": cart.lines,
            "total": cur.format(cart.total),
            "currency_symbol": cur.symbol,
            "has_unavailable": cart.has_unavailable,
            "lang": current_lang(&jar),
        }),
    )?;
    let user = guard::current_user(&ctx, &jar).await;
    let jar = persist_cart(&ctx, jar, user.as_ref(), &cart.stored).await?;
    Ok((jar, response).into_response())
}

pub fn routes() -> Routes {
    Routes::new()
        .add("/cart", get(show))
        .add("/cart/add", post(add))
        .add("/cart/update", post(update))
        .add("/cart/remove", post(remove))
        .add("/partials/cart", get(preview))
}
