"""Auction logic — MPS-20.

Pure functions for bid validation, soft-close math, and proxy resolution.
An orchestrator (place_bid) writes the bid + applies side effects.

Soft-close: a bid placed within `soft_close_seconds` of `end_timestamp`
extends the end by `soft_close_seconds`. eBay-style anti-snipe.

Proxy bidding (eBay-style):
- Each bidder may submit a `max_proxy_in_cents` — the secret ceiling they're
  willing to pay. The visible bid (`amount_in_cents`) is what shows on the
  auction page; the proxy is hidden.
- When a new bid arrives, we compare the new bidder's max_proxy to the
  current winner's max_proxy. The bidder with the higher proxy stays
  winning at min(loser_proxy + increment, winner_proxy). The loser sees
  their bid recorded at their actual amount, but they're outbid.
- A new bid where max_proxy is None defaults to the visible amount —
  no auto-incrementing.

Tie-breaking: identical proxies → the existing winner stays (first-in wins).
"""

from ..models.auction import (
    AUCTION_STATE_ACTIVE,
    MpsAuction,
    MpsBid,
    now_timestamp,
)


class BidRejected(Exception):
    """Raised when a bid cannot be accepted. Reason in .args[0]."""


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


def validate_bid(
    auction_state,
    current_high_in_cents,
    bid_increment_in_cents,
    start_price_in_cents,
    has_bids,
    amount_in_cents,
    max_proxy_in_cents=None,
):
    """Raise BidRejected if the bid is invalid; return None otherwise.

    Pure: no side effects, no DB. Caller passes raw scalars so this fn is
    100% unit-testable without a fixture.

    Rules:
    - auction must be in ACTIVE state
    - amount must be a positive integer
    - first bid: amount >= start_price
    - subsequent bids: amount >= current_high + increment
    - max_proxy (if set) must be >= amount
    """
    if auction_state != AUCTION_STATE_ACTIVE:
        raise BidRejected("auction is not active")

    if not isinstance(amount_in_cents, int) or amount_in_cents <= 0:
        raise BidRejected("bid amount must be a positive integer (cents)")

    if max_proxy_in_cents is not None:
        if not isinstance(max_proxy_in_cents, int) or max_proxy_in_cents < amount_in_cents:
            raise BidRejected("max proxy must be >= bid amount")

    if not has_bids:
        if amount_in_cents < start_price_in_cents:
            raise BidRejected(
                f"bid below start price ({start_price_in_cents} cents)"
            )
    else:
        floor = current_high_in_cents + bid_increment_in_cents
        if amount_in_cents < floor:
            raise BidRejected(
                f"bid below current high + increment ({floor} cents)"
            )


def is_within_soft_close(end_timestamp, now_ms, soft_close_seconds):
    """Pure: is `now_ms` inside the soft-close window?"""
    if not end_timestamp or not soft_close_seconds:
        return False
    window_ms = soft_close_seconds * 1000
    return (end_timestamp - now_ms) < window_ms and now_ms <= end_timestamp


def extended_end_timestamp(now_ms, soft_close_seconds):
    """Pure: new end_timestamp after soft-close fires."""
    return now_ms + (soft_close_seconds * 1000)


def resolve_proxy(
    top_amount_in_cents,
    top_max_proxy_in_cents,
    new_amount_in_cents,
    new_max_proxy_in_cents,
    bid_increment_in_cents,
):
    """Pure: given the current top bid and a new incoming bid (with optional
    proxies), return (winning_amount_in_cents, new_bidder_wins).

    - top_max_proxy and new_max_proxy default to their respective visible
      amounts when callers pass None (no proxy submitted).
    - Tie on max_proxy: existing top stays winning (first-in wins).

    Returns:
        (winning_amount_in_cents, new_bidder_wins) where new_bidder_wins
        is True iff the incoming bidder takes the lead.
    """
    top_proxy = (
        top_max_proxy_in_cents
        if top_max_proxy_in_cents is not None
        else top_amount_in_cents
    )
    new_proxy = (
        new_max_proxy_in_cents
        if new_max_proxy_in_cents is not None
        else new_amount_in_cents
    )

    if new_proxy > top_proxy:
        # New bidder wins. Their visible amount auto-increments past
        # the prior top's proxy, capped at their own proxy ceiling.
        winning = min(top_proxy + bid_increment_in_cents, new_proxy)
        # Floor: at least max(new_amount_in_cents, top_amount_in_cents + increment)
        winning = max(winning, new_amount_in_cents)
        return (winning, True)

    # Existing top stays. Their visible amount auto-increments to defend
    # against the new bid, capped at their own proxy ceiling.
    winning = min(new_proxy + bid_increment_in_cents, top_proxy)
    # Floor: never less than current top's visible amount.
    winning = max(winning, top_amount_in_cents)
    return (winning, False)


# ── Orchestrator ─────────────────────────────────────────────────────────────


def place_bid(
    auction,
    bidder,
    amount_in_cents,
    max_proxy_in_cents=None,
    now_ms=None,
):
    """Place a bid on `auction` by `bidder`. Writes the bid, updates the
    auction (soft-close, winning bid pointer), marks the prior winning bid
    as outbid. Caller is responsible for transaction.commit().

    Returns the new MpsBid on success. Raises BidRejected on rejection.

    Block self-bidding (bidder == seller): caller's responsibility — the
    pure validate_bid does not have visibility into seller identity.
    """
    if now_ms is None:
        now_ms = now_timestamp()

    has_bids = auction.bids.count() > 0
    validate_bid(
        auction_state=auction.state,
        current_high_in_cents=auction.current_high_in_cents,
        bid_increment_in_cents=auction.bid_increment_in_cents,
        start_price_in_cents=auction.start_price_in_cents,
        has_bids=has_bids,
        amount_in_cents=amount_in_cents,
        max_proxy_in_cents=max_proxy_in_cents,
    )

    dbsession = auction.dbsession

    # Find the current winning bid (if any) — only one per auction.
    prior_winner = (
        dbsession.query(MpsBid)
        .filter(MpsBid.auction_id == auction.id, MpsBid.is_winning.is_(True))
        .one_or_none()
    )

    bid = MpsBid(
        auction=auction,
        bidder=bidder,
        amount_in_cents=amount_in_cents,
        max_proxy_in_cents=max_proxy_in_cents,
    )

    if prior_winner is None:
        # First bid takes the lead at face value.
        bid.is_winning = True
        dbsession.add(bid)
    else:
        winning_amount, new_bidder_wins = resolve_proxy(
            top_amount_in_cents=prior_winner.amount_in_cents,
            top_max_proxy_in_cents=prior_winner.max_proxy_in_cents,
            new_amount_in_cents=amount_in_cents,
            new_max_proxy_in_cents=max_proxy_in_cents,
            bid_increment_in_cents=auction.bid_increment_in_cents,
        )
        if new_bidder_wins:
            prior_winner.is_winning = False
            prior_winner.outbid_timestamp = now_ms
            bid.amount_in_cents = winning_amount
            bid.is_winning = True
            dbsession.add(bid)
        else:
            # New bid is recorded but loses; defending top auto-bids up.
            bid.is_winning = False
            bid.outbid_timestamp = now_ms
            dbsession.add(bid)
            prior_winner.amount_in_cents = winning_amount

    # Soft-close: extend if bid arrived in the closing window.
    if is_within_soft_close(
        auction.end_timestamp, now_ms, auction.soft_close_seconds,
    ):
        auction.end_timestamp = extended_end_timestamp(
            now_ms, auction.soft_close_seconds,
        )

    auction.updated_timestamp = now_ms
    dbsession.flush()
    return bid
