from __future__ import annotations
import base64
import hashlib
import json
from datetime import datetime
from decimal import Decimal

from fastapi import HTTPException
from sqlalchemy import and_, func, or_, select

from app.core.models.category import Category
from app.core.models.product import Product, ProductVariant, product_categories
from app.plugins.pos_sync.revision import get_revision
from app.plugins.tax.calculator import (
    _get_default_tax_class_id,
    _load_tax_settings,
    _match_rates,
)


def cache_key(product_id: int, variant_id: int | None) -> str:
    return f"p:{product_id}:v:{variant_id or 0}"


def encode_cursor(updated_at: str, product_id: int, variant_cursor_id: int) -> str:
    raw = json.dumps([updated_at, product_id, variant_cursor_id], separators=(",", ":"))
    return base64.urlsafe_b64encode(raw.encode()).decode()


def decode_cursor(cur: str) -> tuple[str, int, int]:
    u, p, v = json.loads(base64.urlsafe_b64decode(cur.encode()).decode())
    return u, int(p), int(v)


def pick_primary_category(categories: list[dict]) -> dict:
    active = [c for c in categories if c.get("is_active")]
    if not active:
        return {"id": None, "name": None, "sort_order": None}
    first = min(active, key=lambda c: (c["sort_order"], c["id"]))
    return {"id": first["id"], "name": first["name"], "sort_order": first["sort_order"]}


def tax_version(rows: list[dict]) -> str:
    """Deletion-safe: SHA-256 over id-ordered tax rows (key fields + updated_at)."""
    ordered = sorted(rows, key=lambda r: r["id"])
    raw = json.dumps(ordered, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(raw.encode()).hexdigest()[:16]


def serialise_row(*, product, variant, primary_cat, effective_rate, prices_include_tax, tax_ver,
                  price_rules=None) -> dict:
    """PURE (no db). Map one product(+optional variant) row to the POS snapshot item dict."""
    sold_by_weight = bool(product.get_attribute("sold_by_weight", False))
    modifier = variant.price_modifier if variant else 0
    pc = primary_cat or {}
    updated = max(product.updated_at, variant.updated_at if variant else product.updated_at)
    try:
        minimum_age = int(product.get_attribute("pos_minimum_age"))
    except (TypeError, ValueError):
        minimum_age = None
    if minimum_age is not None and minimum_age <= 0:
        minimum_age = None
    # 仅输出 channel_scope 为 'pos' 或 'both' 的规则，store-only 规则对 POS 无意义
    pos_rules = []
    for r in (price_rules or []):
        if r.channel_scope not in ("pos", "both"):
            continue
        pos_rules.append({
            "id": r.id,
            "channelScope": r.channel_scope,
            "variantId": r.variant_id,
            "memberLevelId": r.member_level_id,
            "minQuantity": r.min_quantity,
            "priceType": r.price_type,
            "priceValue": str(r.price_value),
            "priority": r.priority,
            "isPromotion": bool(r.is_promotion),
            "startsAt": r.starts_at.isoformat() if r.starts_at else None,
            "endsAt": r.ends_at.isoformat() if r.ends_at else None,
            "isActive": bool(r.is_active),
        })
    return {
        "cloudProductId": product.id,
        "cloudVariantId": variant.id if variant else None,
        "sku": variant.sku if variant else product.sku,
        "name": product.name,
        "nameEn": getattr(product, "name_en", None),
        "unit": "kg" if sold_by_weight else "ea",
        "soldByWeight": sold_by_weight,
        "priceCents": int(round((product.base_price + modifier) * 100)),
        "pricesIncludeTax": bool(prices_include_tax),
        "effectiveTaxRate": str(effective_rate),
        "taxSnapshotVersion": tax_ver,
        "categoryId": pc.get("id"),
        "categoryName": pc.get("name"),
        "categorySortOrder": pc.get("sort_order"),
        "isActive": (product.status == "active") and (variant.is_active == 1 if variant else True),
        "updatedAt": updated.isoformat(),
        "minimumAge": minimum_age,
        "priceRules": pos_rules,
    }


def _parse_dt(value: str) -> datetime:
    return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)


async def _compute_tax_version(db, tenant_id: int) -> str:
    from app.plugins.tax.models import TaxClass, TaxRate, TaxSettings

    rows: list[dict] = []
    for s in (await db.execute(select(TaxSettings).where(TaxSettings.tenant_id == tenant_id))).scalars().all():
        rows.append({
            "id": f"s:{s.id}",
            "k": [str(s.prices_include_tax), str(s.rounding_mode), str(s.tax_shipping), str(s.shipping_tax_class_id)],
            "updated_at": s.updated_at.isoformat() if s.updated_at else "",
        })
    for c in (await db.execute(select(TaxClass).where(TaxClass.tenant_id == tenant_id))).scalars().all():
        rows.append({
            "id": f"c:{c.id}",
            "k": [str(c.name), str(c.is_default)],
            "updated_at": c.updated_at.isoformat() if c.updated_at else "",
        })
    for r in (await db.execute(select(TaxRate).where(TaxRate.tenant_id == tenant_id))).scalars().all():
        rows.append({
            "id": f"r:{r.id}",
            "k": [str(r.tax_class_id), str(r.country), str(r.province), str(r.rate), str(r.priority), str(r.compound)],
            "updated_at": r.updated_at.isoformat() if r.updated_at else "",
        })
    return tax_version(rows)


async def build_snapshot_page(db, lane, *, since, cursor, limit, snapshot_at, expected_revision) -> dict:
    if not lane.store_country:
        raise HTTPException(status_code=422, detail="lane needs store_country before snapshot")

    tenant_id = lane.tenant_id
    now_rev = await get_revision(db, tenant_id)
    if expected_revision is not None and expected_revision != now_rev:
        raise HTTPException(status_code=409, detail="catalogue_changed")

    snap = snapshot_at or datetime.utcnow()
    tax_ver = await _compute_tax_version(db, tenant_id)

    # Effective row timestamp = max(product, variant); used for filter, ordering AND cursor so
    # the emitted nextCursor matches serialise_row's updatedAt (keyset stays consistent).
    eff_updated = func.greatest(
        Product.updated_at, func.coalesce(ProductVariant.updated_at, Product.updated_at)
    )
    vcur = func.coalesce(ProductVariant.id, 0)

    conds = [Product.tenant_id == tenant_id, eff_updated <= snap]
    if since and since != "0":  # agent sends '0' as the full-pull sentinel, not an ISO datetime
        conds.append(eff_updated > _parse_dt(since))
    if cursor:
        cu, cp, cv = decode_cursor(cursor)
        cu_dt = _parse_dt(cu)
        conds.append(or_(
            eff_updated > cu_dt,
            and_(eff_updated == cu_dt, Product.id > cp),
            and_(eff_updated == cu_dt, Product.id == cp, vcur > cv),
        ))

    stmt = (
        select(Product, ProductVariant)
        .outerjoin(ProductVariant, and_(
            ProductVariant.product_id == Product.id,
            ProductVariant.tenant_id == tenant_id,
        ))
        .where(*conds)
        .order_by(eff_updated.asc(), Product.id.asc(), vcur.asc())
        .limit(limit)
    )
    rows = (await db.execute(stmt)).all()

    # Resolve tax once per tax_class_id (avoid N+1).
    settings = await _load_tax_settings(db, tenant_id)
    prices_include_tax = bool(settings.prices_include_tax) if settings else False
    default_class_id = await _get_default_tax_class_id(db, tenant_id)
    rate_cache: dict = {}

    async def resolve_rate(cls_id):
        if cls_id in rate_cache:
            return rate_cache[cls_id]
        if cls_id is None:
            eff = Decimal("0")
        else:
            matched = await _match_rates(db, tenant_id, cls_id, lane.store_country, lane.store_province or "")
            eff = matched[0].rate if len(matched) == 1 else Decimal("0")
        rate_cache[cls_id] = eff
        return eff

    # Resolve categories once for all products in the page.
    product_ids = [p.id for p, _ in rows]
    cats_by_product: dict[int, list[dict]] = {}
    if product_ids:
        cat_rows = (await db.execute(
            select(
                product_categories.c.product_id,
                Category.id, Category.name, Category.sort_order, Category.is_active,
            )
            .join(Category, Category.id == product_categories.c.category_id)
            .where(product_categories.c.product_id.in_(set(product_ids)))
        )).all()
        for pid, cid, cname, csort, cactive in cat_rows:
            cats_by_product.setdefault(pid, []).append(
                {"id": cid, "name": cname, "sort_order": csort, "is_active": cactive}
            )

    # Bulk-load POS 渠道相关的价格规则（按 tenant_id + product_id 一次性拉，内存筛 channel）
    rules_by_product: dict[int, list] = {}
    if product_ids:
        from app.core.models.product import ProductPriceRule
        rule_rows = (await db.execute(
            select(ProductPriceRule).where(
                ProductPriceRule.tenant_id == tenant_id,
                ProductPriceRule.product_id.in_(set(product_ids)),
            )
        )).scalars().all()
        for r in rule_rows:
            if r.channel_scope not in ("pos", "both"):
                continue
            rules_by_product.setdefault(r.product_id, []).append(r)

    items = []
    for product, variant in rows:
        cls_id = product.tax_class_id if product.tax_class_id is not None else default_class_id
        eff_rate = await resolve_rate(cls_id)
        primary = pick_primary_category(cats_by_product.get(product.id, []))
        items.append(serialise_row(
            product=product, variant=variant, primary_cat=primary,
            effective_rate=eff_rate, prices_include_tax=prices_include_tax, tax_ver=tax_ver,
            price_rules=rules_by_product.get(product.id, []),
        ))

    next_cursor = None
    if items:
        last_p, last_v = rows[-1]
        next_cursor = encode_cursor(items[-1]["updatedAt"], last_p.id, last_v.id if last_v else 0)
    has_more = len(items) == limit

    return {
        "items": items,
        "snapshotAt": snap.isoformat(),
        "catalogueRevision": now_rev,
        "nextCursor": next_cursor,
        "hasMore": has_more,
    }
