"""装箱单（文档 6.3、10.5）。"""
import datetime as dt
import uuid
from dataclasses import dataclass

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from flyway.config import settings
from flyway.domain.actor import Actor
from flyway.domain.clock import require_aware
from flyway.domain.errors import BusinessError
from flyway.models import PackingSlip, Transfer
from flyway.services.audit import record
from flyway.services.common import ensure_supplier, norm, require_admin


@dataclass
class PackingSlipHeader:
    reference: str
    date: dt.datetime
    from_location: str
    from_rmp: str
    to_location: str
    to_rmp: str
    awb_number: str | None = None


def _load_outs(session: Session, ids) -> list[Transfer]:
    """按 ID 顺序锁定出库单行后再检查状态（与库存锁同一做法），并发装箱时后到的请求读到最新的装箱单号。"""
    locked = session.scalars(select(Transfer).where(
        Transfer.id.in_(list(ids)), Transfer.is_load_out.is_(True), Transfer.is_local.is_(False)
    ).order_by(Transfer.id).with_for_update().execution_options(populate_existing=True)).all()
    return sorted(locked, key=lambda t: t.code)


def _lock_slip(session: Session, slip_id: uuid.UUID) -> PackingSlip | None:
    return session.scalar(select(PackingSlip).where(PackingSlip.id == slip_id).with_for_update()
                          .execution_options(populate_existing=True))


def _find_slip(session: Session, reference: str, exclude_id=None) -> PackingSlip | None:
    q = select(PackingSlip).where(func.lower(func.trim(PackingSlip.reference)) == norm(reference).lower())
    if exclude_id is not None:
        q = q.where(PackingSlip.id != exclude_id)
    return session.scalar(q)


def _quoted(transfers) -> str:
    return ",".join(f'"{t.code}"' for t in transfers)


def pack_load_outs(session: Session, actor: Actor, load_out_ids, reference: str, now: dt.datetime) -> PackingSlip:
    require_aware(now, "now")
    if not norm(reference):
        raise BusinessError('"Reference" is required.', "reference")
    # 加锁顺序与编辑装箱单相同：先装箱单、后出库单（顺序相反会互相等待而死锁）
    slip = _find_slip(session, reference)
    if slip is not None:
        slip = _lock_slip(session, slip.id)
        # 等锁期间装箱单可能被别人改了编号或删除：拿到锁后按最新数据重新核对
        if slip is None or norm(slip.reference).lower() != norm(reference).lower():
            raise BusinessError(f'Packing slip "{norm(reference)}" was just changed by someone else. '
                                "Please try again.", "reference")
    outs = _load_outs(session, load_out_ids)
    if not outs:
        raise BusinessError("Please select some load-out.", "load_out_ids")
    packed = [t for t in outs if t.packing_slip_id]
    if packed:
        raise BusinessError(f"Unable to pack! {_quoted(packed)} is packed, can't pack again.")
    drafts = [t for t in outs if t.is_draft]
    if drafts:
        raise BusinessError(f"Unable to pack! {_quoted(drafts)} is not completed.")
    first = outs[0]
    mismatched = [t for t in outs if norm(t.to_rmp).lower() != norm(first.to_rmp).lower()]
    if mismatched:
        raise BusinessError(f'Unable to pack! The to-RMP for {_quoted(mismatched)} unequal to "{first.to_rmp}".')
    if slip is not None and norm(slip.to_rmp).lower() != norm(first.to_rmp).lower():
        raise BusinessError(f'Unable to pack! The to-RMP for {_quoted(outs)} unequal to "{slip.to_rmp}" '
                            f'of packing slip "{slip.reference}".')
    if slip is None:
        slip = PackingSlip(
            id=uuid.uuid4(), reference=norm(reference), date=now, operator=actor.full_name,
            from_location=settings.packing_from_location, from_rmp=settings.packing_from_rmp,
            to_location=first.to_location or "", to_rmp=first.to_rmp or "",
        )
        session.add(slip)
        record(session, actor, "create", "packing_slip", slip.id, slip.reference)
    for t in outs:
        t.packing_slip, t.date = slip, slip.date
    record(session, actor, "pack", "packing_slip", slip.id, _quoted(outs))
    session.flush()
    return slip


def unpack_load_outs(session: Session, actor: Actor, load_out_ids) -> None:
    for t in _load_outs(session, load_out_ids):
        if t.packing_slip_id:
            record(session, actor, "unpack", "packing_slip", t.packing_slip_id, t.code)
            t.packing_slip = None
    session.flush()


def save_packing_slip(session: Session, actor: Actor, header: PackingSlipHeader, load_out_ids,
                      slip_id: uuid.UUID | None = None) -> PackingSlip:
    require_aware(header.date, "date")
    ids = list(load_out_ids)
    if not ids:
        raise BusinessError("Please select some load-out.", "load_out_ids")
    reference = norm(header.reference)
    if not reference:
        raise BusinessError('"Reference" is required.', "reference")
    if _find_slip(session, reference, exclude_id=slip_id):
        raise BusinessError(f'The value "{reference}" for "Reference" already exists.', "reference")
    if slip_id:
        slip = _lock_slip(session, slip_id)
        if slip is None:
            raise BusinessError("Record not found.")
        action = "edit"
    else:
        slip = PackingSlip(id=uuid.uuid4(), operator=actor.full_name)
        action = "create"
    old_to_rmp = slip.to_rmp
    slip.reference, slip.date, slip.awb_number = reference, header.date, header.awb_number
    slip.from_location, slip.from_rmp = header.from_location, header.from_rmp
    slip.to_location, slip.to_rmp = header.to_location, header.to_rmp
    session.add(slip)
    current = [t.id for t in slip.transfers] if slip_id else []
    outs = [t for t in _load_outs(session, set(ids) | set(current)) if t.id in set(ids)]
    bad = [t for t in outs if t.is_draft or (t.packing_slip_id not in (None, slip.id))]
    if bad or len(outs) != len(set(ids)):
        raise BusinessError(f"Unable to pack! {_quoted(bad)} is not a completed, unpacked load-out.", "load_out_ids")
    # 与出库列表装箱同一规则（文档 10.5）：出库单目的 RMP 须与装箱单一致。
    # 本单原有出库单在表头 RMP 未改时照旧放行，避免历史上不一致的装箱单（全量数据中 2 张）无法再编辑。
    rmp_kept = slip_id is not None and norm(old_to_rmp).lower() == norm(header.to_rmp).lower()
    mismatched = [t for t in outs if norm(t.to_rmp).lower() != norm(header.to_rmp).lower()
                  and not (rmp_kept and t.id in current)]
    if mismatched:
        raise BusinessError(f'Unable to pack! The to-RMP for {_quoted(mismatched)} unequal to "{header.to_rmp}".',
                            "to_rmp")
    for t in list(slip.transfers):
        if t.id not in ids:
            t.packing_slip = None
    for t in outs:
        t.packing_slip, t.date, t.awb_number = slip, slip.date, slip.awb_number
    ensure_supplier(session, header.from_location, header.from_rmp)
    ensure_supplier(session, header.to_location, header.to_rmp)
    record(session, actor, action, "packing_slip", slip.id, reference)
    session.flush()
    return slip


def set_packing_slip_completed(session: Session, actor: Actor, slip_id: uuid.UUID, completed: bool,
                               now: dt.datetime) -> PackingSlip:
    require_aware(now, "now")
    slip = session.get(PackingSlip, slip_id)
    if slip is None:
        raise BusinessError("Record not found.")
    if (slip.completed is not None) != completed:
        slip.completed = now if completed else None
        record(session, actor, "complete" if completed else "revert", "packing_slip", slip.id, slip.reference)
        session.flush()
    return slip


def delete_packing_slips(session: Session, actor: Actor, ids) -> None:
    require_admin(actor)
    slips = session.scalars(select(PackingSlip).where(PackingSlip.id.in_(list(ids))).order_by(PackingSlip.id)
                            .with_for_update().execution_options(populate_existing=True)).all()
    _load_outs(session, [t.id for slip in slips for t in slip.transfers])  # 同一顺序：装箱单 → 出库单
    for slip in slips:
        for t in list(slip.transfers):
            t.packing_slip = None
        record(session, actor, "delete", "packing_slip", slip.id, slip.reference)
        session.delete(slip)
    session.flush()
