"""Offer logic — MPS-21.

Pure validators + orchestrators for the make-an-offer state machine.
Buyer opens an offer, seller counters or accepts. Either party can
walk away with decline/withdraw. The round counter caps haggling
loops; expiration_hours auto-ages out stale offers.

Auto-accept / auto-decline:
- When buyer opens with amount >= list_price * (auto_accept_threshold_pct / 100),
  the offer skips seller queue and lands in ACCEPTED state.
- When amount < list_price * (auto_decline_threshold_pct / 100),
  the offer goes straight to DECLINED. Seller never sees the lowball.

These thresholds are shop-level settings (offer_auto_accept_threshold_pct,
offer_auto_decline_threshold_pct) with sane defaults (95 and 50).

Self-offer (buyer == seller) blocking is the view layer's job — same
pattern as auctions.
"""

from ..models.offer import (
    MpsOffer,
    MpsOfferEvent,
    OFFER_STATE_PENDING,
    OFFER_STATE_ACCEPTED,
    OFFER_STATE_COUNTERED,
    OFFER_STATE_DECLINED,
    OFFER_STATE_EXPIRED,
    OFFER_STATE_WITHDRAWN,
    OFFER_STATE_PAID,
    OFFER_STATE_BUYER_CANCELLED,
    OFFER_TERMINAL_STATES,
    OFFER_PARTY_BUYER,
    OFFER_PARTY_SELLER,
    OFFER_PARTY_OTHER,
    OFFER_EVENT_OPEN,
    OFFER_EVENT_COUNTER,
    OFFER_EVENT_ACCEPT,
    OFFER_EVENT_DECLINE,
    OFFER_EVENT_WITHDRAW,
    OFFER_EVENT_EXPIRE,
    OFFER_EVENT_PAY,
    OFFER_EVENT_BUYER_CANCEL,
    DEFAULT_OFFER_EXPIRATION_HOURS,
    DEFAULT_OFFER_MAX_ROUNDS,
    DEFAULT_OFFER_AUTO_ACCEPT_PCT,
    DEFAULT_OFFER_AUTO_DECLINE_PCT,
    DEFAULT_OFFER_ACCEPTANCE_PAYMENT_HOURS,
    now_timestamp,
)


class OfferRejected(Exception):
    """Raised when an offer action is invalid. Reason in .args[0]."""


# ── Pure helpers ─────────────────────────────────────────────────────────────


def validate_actor_turn(actor_party, current_party, offer_state):
    """Raise OfferRejected if it's not actor_party's turn to act on a
    pending/countered offer.

    Pure: caller resolves whether the user is buyer or seller and passes
    actor_party as OFFER_PARTY_BUYER or OFFER_PARTY_SELLER.
    """
    if offer_state in OFFER_TERMINAL_STATES:
        raise OfferRejected("offer is terminal")
    if actor_party != current_party:
        raise OfferRejected("not your turn to act on this offer")


def validate_round_cap(round_count, max_rounds):
    """Raise OfferRejected if round_count has reached max_rounds.

    The opening offer is round 0; first counter takes it to 1. Once
    round_count >= max_rounds, no more counters are allowed and the
    current party must accept or decline.
    """
    if round_count >= max_rounds:
        raise OfferRejected(
            f"counter limit ({max_rounds}) reached — accept or decline only"
        )


def validate_floor(amount_in_cents, floor_in_cents):
    """Raise OfferRejected if amount is below shop floor.

    floor_in_cents may be None (no floor configured); pass through as valid.
    """
    if floor_in_cents is None:
        return
    if amount_in_cents < floor_in_cents:
        raise OfferRejected("offer below shop minimum")


def auto_resolve_open(amount_in_cents, list_price_in_cents,
                     auto_accept_pct, auto_decline_pct):
    """Pure: classify a new offer against shop thresholds.

    Returns one of "accept", "decline", or "queue" (seller decides).
    The percentages are integers in [0, 100]; threshold is computed as
    list_price * pct / 100 with truncation.

    auto_accept fires first: amount >= list * accept_pct / 100 → accept.
    auto_decline fires next: amount < list * decline_pct / 100 → decline.
    Otherwise → queue for seller review.
    """
    if list_price_in_cents <= 0:
        return "queue"  # free product or unset; never auto-resolve
    accept_floor = (list_price_in_cents * auto_accept_pct) // 100
    decline_floor = (list_price_in_cents * auto_decline_pct) // 100
    if amount_in_cents >= accept_floor:
        return "accept"
    if amount_in_cents < decline_floor:
        return "decline"
    return "queue"


# ── Orchestrators ────────────────────────────────────────────────────────────


def open_offer(
    dbsession,
    product,
    shop,
    buyer,
    amount_in_cents,
    buyer_message=None,
    now_ms=None,
):
    """Create an MpsOffer and write the OPEN event. Applies auto-accept /
    auto-decline thresholds before queuing for seller. Returns the new
    offer in whichever terminal/non-terminal state results.
    """
    if now_ms is None:
        now_ms = now_timestamp()

    floor = getattr(shop, "offer_min_in_cents", None)
    validate_floor(amount_in_cents, floor)

    expiration_hours = (
        getattr(shop, "offer_expiration_hours", None)
        or DEFAULT_OFFER_EXPIRATION_HOURS
    )
    expires_timestamp = now_ms + (expiration_hours * 3600 * 1000)

    offer = MpsOffer(
        product=product,
        shop=shop,
        buyer=buyer,
        amount_in_cents=amount_in_cents,
        expires_timestamp=expires_timestamp,
        buyer_message=buyer_message,
    )
    dbsession.add(offer)

    dbsession.add(
        MpsOfferEvent(
            offer=offer,
            event_type=OFFER_EVENT_OPEN,
            actor=buyer,
            amount_in_cents=amount_in_cents,
            message=buyer_message,
        )
    )

    # Auto-accept / auto-decline.
    auto_accept_pct = (
        getattr(shop, "offer_auto_accept_threshold_pct", None)
        or DEFAULT_OFFER_AUTO_ACCEPT_PCT
    )
    auto_decline_pct = (
        getattr(shop, "offer_auto_decline_threshold_pct", None)
        or DEFAULT_OFFER_AUTO_DECLINE_PCT
    )
    decision = auto_resolve_open(
        amount_in_cents=amount_in_cents,
        list_price_in_cents=product.price_in_cents,
        auto_accept_pct=auto_accept_pct,
        auto_decline_pct=auto_decline_pct,
    )
    if decision == "accept":
        offer.state = OFFER_STATE_ACCEPTED
        offer.last_action_timestamp = now_ms
        offer.accepted_timestamp = now_ms
        dbsession.add(
            MpsOfferEvent(
                offer=offer,
                event_type=OFFER_EVENT_ACCEPT,
                actor=None,  # system
                amount_in_cents=amount_in_cents,
                message="auto-accept threshold met",
            )
        )
    elif decision == "decline":
        offer.state = OFFER_STATE_DECLINED
        offer.last_action_timestamp = now_ms
        dbsession.add(
            MpsOfferEvent(
                offer=offer,
                event_type=OFFER_EVENT_DECLINE,
                actor=None,
                amount_in_cents=amount_in_cents,
                message="auto-decline threshold not met",
            )
        )

    dbsession.flush()
    return offer


def counter_offer(
    offer,
    actor,
    actor_party,
    new_amount_in_cents,
    message=None,
    now_ms=None,
):
    """The current_party counters with a new amount. Flips current_party
    to the other side, increments round_count, sets state to COUNTERED.
    Raises OfferRejected if it's not the actor's turn or the round cap
    has been reached.
    """
    if now_ms is None:
        now_ms = now_timestamp()

    validate_actor_turn(actor_party, offer.current_party, offer.state)

    shop = offer.shop
    max_rounds = getattr(shop, "offer_max_rounds", None) or DEFAULT_OFFER_MAX_ROUNDS
    validate_round_cap(offer.round_count, max_rounds)
    validate_floor(new_amount_in_cents, getattr(shop, "offer_min_in_cents", None))

    offer.current_amount_in_cents = new_amount_in_cents
    offer.current_party = OFFER_PARTY_OTHER[offer.current_party]
    offer.last_action_timestamp = now_ms
    offer.round_count += 1
    offer.state = OFFER_STATE_COUNTERED
    if actor_party == OFFER_PARTY_SELLER:
        offer.seller_message = message
    else:
        offer.buyer_message = message

    dbsession = offer.dbsession
    dbsession.add(
        MpsOfferEvent(
            offer=offer,
            event_type=OFFER_EVENT_COUNTER,
            actor=actor,
            amount_in_cents=new_amount_in_cents,
            message=message,
        )
    )
    dbsession.flush()
    return offer


def accept_offer(offer, actor, actor_party, message=None, now_ms=None):
    """Accept whatever amount is currently on the table. Either party
    can accept what's been countered to them. Terminal."""
    if now_ms is None:
        now_ms = now_timestamp()
    validate_actor_turn(actor_party, offer.current_party, offer.state)

    offer.state = OFFER_STATE_ACCEPTED
    offer.last_action_timestamp = now_ms
    offer.accepted_timestamp = now_ms

    dbsession = offer.dbsession
    dbsession.add(
        MpsOfferEvent(
            offer=offer,
            event_type=OFFER_EVENT_ACCEPT,
            actor=actor,
            amount_in_cents=offer.current_amount_in_cents,
            message=message,
        )
    )
    dbsession.flush()
    return offer


def cancel_offer_after_accept(offer, actor, message=None, now_ms=None):
    """Buyer-side back-out *after* the seller accepted. Flips ACCEPTED →
    BUYER_CANCELLED. Distinct from withdraw (which only works before
    acceptance). Terminal. Caller validates that actor is the buyer.
    Raises OfferRejected if the offer isn't currently ACCEPTED — already
    paid, expired, or never-accepted offers cannot be cancelled this way.
    """
    if now_ms is None:
        now_ms = now_timestamp()
    if offer.state != OFFER_STATE_ACCEPTED:
        raise OfferRejected("only accepted offers can be cancelled by buyer")

    offer.state = OFFER_STATE_BUYER_CANCELLED
    offer.last_action_timestamp = now_ms

    dbsession = offer.dbsession
    dbsession.add(
        MpsOfferEvent(
            offer=offer,
            event_type=OFFER_EVENT_BUYER_CANCEL,
            actor=actor,
            amount_in_cents=offer.current_amount_in_cents,
            message=message,
        )
    )
    dbsession.flush()
    return offer


def decline_offer(offer, actor, actor_party, message=None, now_ms=None):
    """Decline the current amount. Terminal. Either party can decline."""
    if now_ms is None:
        now_ms = now_timestamp()
    validate_actor_turn(actor_party, offer.current_party, offer.state)

    offer.state = OFFER_STATE_DECLINED
    offer.last_action_timestamp = now_ms

    dbsession = offer.dbsession
    dbsession.add(
        MpsOfferEvent(
            offer=offer,
            event_type=OFFER_EVENT_DECLINE,
            actor=actor,
            amount_in_cents=offer.current_amount_in_cents,
            message=message,
        )
    )
    dbsession.flush()
    return offer


def withdraw_offer(offer, actor, message=None, now_ms=None):
    """Buyer pulls the offer. Terminal. Buyer-only — caller validates
    identity. We accept withdraw at any non-terminal state.
    """
    if now_ms is None:
        now_ms = now_timestamp()
    if offer.state in OFFER_TERMINAL_STATES:
        raise OfferRejected("offer is terminal")

    offer.state = OFFER_STATE_WITHDRAWN
    offer.last_action_timestamp = now_ms

    dbsession = offer.dbsession
    dbsession.add(
        MpsOfferEvent(
            offer=offer,
            event_type=OFFER_EVENT_WITHDRAW,
            actor=actor,
            amount_in_cents=offer.current_amount_in_cents,
            message=message,
        )
    )
    dbsession.flush()
    return offer


def expire_offer(offer, now_ms=None):
    """System action: flip a non-paid offer past its current deadline to
    EXPIRED. Two deadlines apply at different states:

    - PENDING / COUNTERED: offer.expires_timestamp (negotiation window).
    - ACCEPTED: offer.acceptance_pay_deadline_ms (buyer's pay window).

    Idempotent — offers that are already terminal in a non-expirable way
    (DECLINED, WITHDRAWN, PAID, BUYER_CANCELLED) are returned unchanged.
    Caller (tick job) is responsible for finding eligible offers.
    """
    if now_ms is None:
        now_ms = now_timestamp()

    if offer.state in (OFFER_STATE_PENDING, OFFER_STATE_COUNTERED):
        if offer.expires_timestamp > now_ms:
            return offer  # negotiation window still open
        reason = "auto-expired (negotiation window)"
    elif offer.state == OFFER_STATE_ACCEPTED:
        deadline = offer.acceptance_pay_deadline_ms
        if deadline is None or deadline > now_ms:
            return offer  # buyer still inside the pay window
        reason = "auto-expired (unpaid past acceptance window)"
    else:
        # DECLINED, WITHDRAWN, EXPIRED, PAID, BUYER_CANCELLED — no-op.
        return offer

    offer.state = OFFER_STATE_EXPIRED
    offer.last_action_timestamp = now_ms

    dbsession = offer.dbsession
    dbsession.add(
        MpsOfferEvent(
            offer=offer,
            event_type=OFFER_EVENT_EXPIRE,
            actor=None,
            amount_in_cents=offer.current_amount_in_cents,
            message=reason,
        )
    )
    dbsession.flush()
    return offer


def mark_paid(offer, now_ms=None):
    """Cart-success hook. Flips ACCEPTED → PAID. No-op if already paid."""
    if now_ms is None:
        now_ms = now_timestamp()
    if offer.state == OFFER_STATE_PAID:
        return offer
    if offer.state != OFFER_STATE_ACCEPTED:
        raise OfferRejected("only accepted offers can be marked paid")

    offer.state = OFFER_STATE_PAID
    offer.last_action_timestamp = now_ms
    offer.paid_timestamp = now_ms

    dbsession = offer.dbsession
    dbsession.add(
        MpsOfferEvent(
            offer=offer,
            event_type=OFFER_EVENT_PAY,
            actor=None,
            amount_in_cents=offer.current_amount_in_cents,
            message="cart payment captured",
        )
    )
    dbsession.flush()
    return offer
