"""通用打包分箱引擎

核心抽象：
  ShipmentItem  — 订单商品单元（带 attributes 属性袋）
  PackingRule   — 规则条目（selector + rule_type + params）
  Box           — 包裹（若干已展开的单件商品）

规则类型：
  LIMIT       — 限制匹配商品的数量/重量/价值上限
  UNIQUE      — 同箱内匹配商品的某字段只能出现一种值
  EXCLUSIVE   — 匹配商品必须独占一箱（不与其他类型混装）
  FORBIDDEN   — 匹配商品禁止发货，直接报错不进入分箱
  INCOMPATIBLE— 两组商品不能出现在同一箱
  MIXED_LIMIT — 多品类混装时，取各品类对应 LIMIT 规则的最小 max 作为整箱数量上限

selector 字段支持：
  "weight" / "value"               → item.weight / item.value
  "attributes.<key>"               → item.attributes[key]
  "quantity"                       → 始终为 1（已展开）

selector operator：
  eq | neq | in | not_in | contains | exists
"""
from __future__ import annotations

import uuid
from dataclasses import dataclass, field
from typing import Any


# ─────────────────────────────────────────────
#  数据结构
# ─────────────────────────────────────────────

@dataclass
class ShipmentItem:
    """单件商品（quantity 始终为 1，调用前由 expand_items 展开）"""
    id: str
    sku_id: str
    name: str
    weight: float       # 单件重量 kg
    value: float        # 单件申报价值（货币单位由调用方约定）
    attributes: dict[str, Any] = field(default_factory=dict)
    quantity: int = 1   # 展开后固定为 1，保留字段供调试


@dataclass
class Box:
    id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
    items: list[ShipmentItem] = field(default_factory=list)

    @property
    def total_weight(self) -> float:
        return sum(i.weight for i in self.items)

    @property
    def total_value(self) -> float:
        return sum(i.value for i in self.items)

    @property
    def total_quantity(self) -> int:
        return len(self.items)


@dataclass
class RuleResult:
    valid: bool
    message: str = ""


@dataclass
class PackResult:
    boxes: list[Box]
    forbidden: list[dict]   # [{"item": ShipmentItem, "reason": str}]


# ─────────────────────────────────────────────
#  选择器求值
# ─────────────────────────────────────────────

def _get_attr(item: ShipmentItem, field_path: str) -> Any:
    """从 item 取字段值，支持 'attributes.xxx' 嵌套路径"""
    if field_path.startswith("attributes."):
        key = field_path[len("attributes."):]
        return item.attributes.get(key)
    return getattr(item, field_path, None)


def _matches(item: ShipmentItem, selector: dict | None) -> bool:
    """判断 item 是否满足 selector；selector=None 表示匹配所有"""
    if selector is None:
        return True
    val = _get_attr(item, selector["field"])
    op = selector["operator"]
    target = selector.get("value")

    if op == "eq":      return val == target
    if op == "neq":     return val != target
    if op == "in":      return val in (target or [])
    if op == "not_in":  return val not in (target or [])
    if op == "contains":return target in (val or "")
    if op == "exists":  return val is not None
    return False


# ─────────────────────────────────────────────
#  度量计算
# ─────────────────────────────────────────────

def _measure(items: list[ShipmentItem], metric: str) -> float:
    if metric == "quantity": return len(items)
    if metric == "weight":   return sum(i.weight for i in items)
    if metric == "value":    return sum(i.value for i in items)
    return 0.0


# ─────────────────────────────────────────────
#  规则执行器
# ─────────────────────────────────────────────

def _apply_limit(box: Box, rule: dict, _all: list[dict]) -> RuleResult:
    """LIMIT — 限制匹配商品的数量/重量/价值"""
    sel = rule.get("selector")
    params = rule["params"]
    metric = params["metric"]
    max_val = params["max"]

    matching = [i for i in box.items if _matches(i, sel)]
    if not matching:
        return RuleResult(True)

    # appliesWhen: box_only_has_matching_items → 纯装时才生效
    if params.get("appliesWhen") == "box_only_has_matching_items":
        if len(matching) != len(box.items):
            return RuleResult(True)

    total = _measure(matching, metric)
    if total > max_val:
        msg = rule.get("message") or f"[{rule.get('name','')}] {metric}={total} 超出上限 {max_val}"
        return RuleResult(False, msg)
    return RuleResult(True)


def _apply_unique(box: Box, rule: dict, _all: list[dict]) -> RuleResult:
    """UNIQUE — 匹配商品的指定字段在同箱内只能有一种值"""
    sel = rule.get("selector")
    unique_field = rule["params"]["uniqueField"]
    matching = [i for i in box.items if _matches(i, sel)]
    if not matching:
        return RuleResult(True)

    values = {_get_attr(i, unique_field) for i in matching}
    if len(values) > 1:
        msg = rule.get("message") or f"[{rule.get('name','')}] {unique_field} 同箱只能有一种值，当前: {values}"
        return RuleResult(False, msg)
    return RuleResult(True)


def _apply_exclusive(box: Box, rule: dict, _all: list[dict]) -> RuleResult:
    """EXCLUSIVE — 匹配商品必须独占一箱，且自身数量 ≤ maxQuantity"""
    sel = rule.get("selector")
    max_qty = rule["params"].get("maxQuantity", 1)

    matching = [i for i in box.items if _matches(i, sel)]
    if not matching:
        return RuleResult(True)

    non_matching = [i for i in box.items if not _matches(i, sel)]
    if non_matching:
        msg = rule.get("message") or f"[{rule.get('name','')}] 独占品类不可与其他商品同箱"
        return RuleResult(False, msg)

    if len(matching) > max_qty:
        msg = rule.get("message") or f"[{rule.get('name','')}] 独占品类最多 {max_qty} 件，当前 {len(matching)}"
        return RuleResult(False, msg)

    return RuleResult(True)


def _apply_forbidden(box: Box, rule: dict, _all: list[dict]) -> RuleResult:
    """FORBIDDEN — 匹配商品不应进入 box（autoPack 已提前过滤，此处仅作二次校验）"""
    sel = rule.get("selector")
    forbidden_items = [i for i in box.items if _matches(i, sel)]
    if forbidden_items:
        msg = rule.get("message") or f"[{rule.get('name','')}] 含禁运商品"
        return RuleResult(False, msg)
    return RuleResult(True)


def _apply_incompatible(box: Box, rule: dict, _all: list[dict]) -> RuleResult:
    """INCOMPATIBLE — selector 命中的商品不能与 params.with 命中的商品同箱"""
    sel = rule.get("selector")
    with_sel = rule["params"].get("with")
    if not with_sel:
        return RuleResult(True)

    has_a = any(_matches(i, sel) for i in box.items)
    has_b = any(_matches(i, with_sel) for i in box.items)
    if has_a and has_b:
        msg = rule.get("message") or f"[{rule.get('name','')}] 互斥商品不可同箱"
        return RuleResult(False, msg)
    return RuleResult(True)


def _apply_mixed_limit(box: Box, rule: dict, all_rules: list[dict]) -> RuleResult:
    """MIXED_LIMIT — 多品类混装时，取各品类对应 LIMIT 规则的最小 max 作为整箱上限"""
    params = rule["params"]
    group_by = params["groupBy"]
    metric = params.get("metric", "quantity")
    limit_rule_ids = params.get("limitRuleIds")  # 可选：显式指定关联 LIMIT 规则 ID

    # 收集箱内出现的 group 值
    groups: set = set()
    for item in box.items:
        val = _get_attr(item, group_by)
        if val is not None:
            groups.add(val)

    if len(groups) <= 1:
        return RuleResult(True)  # 单品类，由 LIMIT 规则自行处理

    # 找出各 group 对应的 LIMIT 规则
    if limit_rule_ids:
        limit_rules = [r for r in all_rules if r.get("id") in limit_rule_ids and r["rule_type"] == "LIMIT"]
    else:
        # 自动匹配：LIMIT 规则 selector.field == group_by 且 metric 相同
        limit_rules = [
            r for r in all_rules
            if r["rule_type"] == "LIMIT"
            and r.get("selector")
            and r["selector"].get("field") == group_by
            and r["params"].get("metric") == metric
        ]

    # 找出所有在箱内出现的 group 中最小的 max
    min_limit: float | None = None
    for group_val in groups:
        for lr in limit_rules:
            sel = lr.get("selector", {})
            if sel.get("operator") == "eq" and sel.get("value") == group_val:
                lim = lr["params"]["max"]
                if min_limit is None or lim < min_limit:
                    min_limit = lim
                break

    if min_limit is None:
        return RuleResult(True)

    total = _measure(box.items, metric)
    if total > min_limit:
        msg = rule.get("message") or (
            f"[{rule.get('name','')}] 混装时 {metric} 上限为 {min_limit}（取最小品类限制），当前 {total}"
        )
        return RuleResult(False, msg)
    return RuleResult(True)


_RULE_HANDLERS = {
    "LIMIT":       _apply_limit,
    "UNIQUE":      _apply_unique,
    "EXCLUSIVE":   _apply_exclusive,
    "FORBIDDEN":   _apply_forbidden,
    "INCOMPATIBLE":_apply_incompatible,
    "MIXED_LIMIT": _apply_mixed_limit,
}


# ─────────────────────────────────────────────
#  校验器
# ─────────────────────────────────────────────

def validate_box(box: Box, rules: list[dict]) -> RuleResult:
    """对一个包裹执行全部启用规则，返回第一个失败结果或 valid=True"""
    for rule in rules:
        if not rule.get("is_active", True):
            continue
        handler = _RULE_HANDLERS.get(rule["rule_type"])
        if handler is None:
            continue
        result = handler(box, rule, rules)
        if not result.valid:
            return result
    return RuleResult(True)


def can_place(item: ShipmentItem, box: Box, rules: list[dict]) -> bool:
    """临时将 item 放入 box，检验规则，随后恢复；返回是否可放入"""
    box.items.append(item)
    result = validate_box(box, rules)
    box.items.pop()
    return result.valid


# ─────────────────────────────────────────────
#  排序：优先放最难放的商品
# ─────────────────────────────────────────────

def _difficulty_key(item: ShipmentItem, rules: list[dict]):
    """
    返回排序 key（升序 = 优先处理）：
      (0=EXCLUSIVE, 1=其他)  →  独占商品最先
      (0=UNIQUE, 1=其他)     →  唯一性约束次之
      min_qty_limit          →  件数上限越小越靠前
      -value                 →  价值高的优先
      -weight                →  重量大的优先
    """
    has_exclusive = any(
        r["rule_type"] == "EXCLUSIVE" and _matches(item, r.get("selector"))
        for r in rules if r.get("is_active", True)
    )
    has_unique = any(
        r["rule_type"] == "UNIQUE" and _matches(item, r.get("selector"))
        for r in rules if r.get("is_active", True)
    )
    min_qty = min(
        (r["params"]["max"] for r in rules
         if r.get("is_active", True)
         and r["rule_type"] == "LIMIT"
         and r.get("selector")
         and _matches(item, r["selector"])
         and r["params"].get("metric") == "quantity"),
        default=float("inf"),
    )
    return (0 if has_exclusive else 1, 0 if has_unique else 1, min_qty, -item.value, -item.weight)


# ─────────────────────────────────────────────
#  主入口
# ─────────────────────────────────────────────

def expand_items(items: list[ShipmentItem]) -> list[ShipmentItem]:
    """将每个 ShipmentItem 按 quantity 展开为若干单件"""
    result: list[ShipmentItem] = []
    for item in items:
        for _ in range(max(int(item.quantity), 1)):
            result.append(ShipmentItem(
                id=item.id,
                sku_id=item.sku_id,
                name=item.name,
                weight=item.weight,
                value=item.value,
                attributes=item.attributes,
                quantity=1,
            ))
    return result


def auto_pack(items: list[ShipmentItem], rules: list[dict]) -> PackResult:
    """
    自动分箱主函数：
      1. 展开 quantity → 单件列表
      2. 分离 FORBIDDEN 商品（不参与分箱）
      3. 按难度排序
      4. First-Fit 贪心：每件商品找第一个放得下的箱；否则开新箱
    """
    active_rules = sorted(
        [r for r in rules if r.get("is_active", True)],
        key=lambda r: r.get("priority", 0),
    )

    # ① 展开
    all_units = expand_items(items)

    # ② 分离禁运
    forbidden_results: list[dict] = []
    packable: list[ShipmentItem] = []
    forbidden_rules = [r for r in active_rules if r["rule_type"] == "FORBIDDEN"]
    for unit in all_units:
        blocked = next(
            (r for r in forbidden_rules if _matches(unit, r.get("selector"))),
            None,
        )
        if blocked:
            forbidden_results.append({
                "item": unit,
                "reason": blocked.get("message") or f"[{blocked.get('name','')}] 禁运商品",
            })
        else:
            packable.append(unit)

    # ③ 排序
    sorted_units = sorted(packable, key=lambda i: _difficulty_key(i, active_rules))

    # ④ First-Fit 分箱
    boxes: list[Box] = []
    for unit in sorted_units:
        placed = False
        for box in boxes:
            if can_place(unit, box, active_rules):
                box.items.append(unit)
                placed = True
                break
        if not placed:
            new_box = Box()
            new_box.items.append(unit)
            boxes.append(new_box)

    return PackResult(boxes=boxes, forbidden=forbidden_results)
