"""Auction views — MPS-20.

Routes:
    GET  /a/{auction_id}            — live auction page
    GET  /a/{auction_id}.json       — JSON state for poll
    POST /a/{auction_id}/bid        — place a bid (requires login)
    POST /a/{auction_id}/buy-now    — buy at buy_now price (mode 2 only)
    POST /a/{auction_id}/watch      — toggle watcher

Self-bid blocking, shop owner check, and identity resolution happen here.
The pure logic in lib/auction.py does not have visibility into who owns
which shop.
"""

import logging

from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from pyramid.view import view_config

from ..lib.auction import (
    BidRejected,
    place_bid,
    is_within_soft_close,
    extended_end_timestamp,
)
from ..lib.mail import send_auction_outbid_email
from ..lib.currency import cents_to_dollars
from ..models.auction import (
    AUCTION_STATE_ACTIVE,
    AUCTION_STATE_ENDED,
    AUCTION_STATE_SETTLED,
    MpsAuction,
    MpsAuctionWatcher,
    get_auction_by_id,
    now_timestamp,
)
from ..views import user_required


def _is_ajax(request):
    return request.headers.get("X-Requested-With") == "XMLHttpRequest"


def _user_is_seller(request, auction):
    """True iff request.user is one of the auction's shop owners."""
    if request.user is None:
        return False
    return request.user in auction.shop.owners


def _serialize_auction(auction, request):
    """Shape used by both auction.j2 (template ctx) and the JSON poll."""
    current_winning = (
        auction.bids
        .filter_by(is_winning=True)
        .one_or_none()
    )
    return {
        "id": auction.uuid_str,
        "product_title": auction.product.title,
        "product_url": f"/p/{auction.product.uuid_str}/{auction.product.slug}",
        "shop_url": f"/s/{auction.shop.uuid_str}",
        "state": auction.state,
        "state_human": auction.state_human,
        "is_active": auction.is_active,
        "is_ended": auction.is_ended,
        "is_terminal": auction.is_terminal,
        "start_timestamp": auction.start_timestamp,
        "end_timestamp": auction.end_timestamp,
        "time_remaining_ms": auction.time_remaining_ms,
        "start_price_in_cents": auction.start_price_in_cents,
        "start_price": auction.start_price,
        "current_high_in_cents": auction.current_high_in_cents,
        "current_high": auction.current_high,
        "min_next_bid_in_cents": auction.min_next_bid_in_cents,
        "min_next_bid": auction.min_next_bid,
        "bid_increment_in_cents": auction.bid_increment_in_cents,
        "bid_increment": auction.bid_increment,
        "has_buy_now": auction.has_buy_now,
        "buy_now_price_in_cents": auction.buy_now_price_in_cents,
        "buy_now_price": auction.buy_now_price,
        "has_reserve": auction.has_reserve,
        "reserve_met": auction.reserve_met,
        "winner_user_id": (
            auction.winner.uuid_str if auction.winner else None
        ),
        "current_winning_user_id": (
            current_winning.bidder.uuid_str if current_winning else None
        ),
        "currency": auction.currency,
        "user_is_seller": _user_is_seller(request, auction),
        "user_is_winning": (
            request.user is not None
            and current_winning is not None
            and current_winning.bidder == request.user
        ),
        "user_is_winner": (
            request.user is not None
            and auction.winner is not None
            and auction.winner == request.user
        ),
        "payment_deadline_timestamp": auction.payment_deadline_timestamp,
        "payment_deadline_human": _human_delta(auction.payment_deadline_timestamp),
        "is_settled": auction.state == AUCTION_STATE_SETTLED,
    }


def _human_delta(ms_timestamp):
    """Render an absolute ms timestamp as a relative human delta
    ("in 1 day, 5 hours" / "expired 2 minutes ago") for no-JS callers.
    JS refines to second precision client-side.
    """
    if ms_timestamp is None:
        return None
    import ago
    from datetime import datetime, timezone
    dt = datetime.fromtimestamp(ms_timestamp / 1000, tz=timezone.utc)
    return ago.human(
        dt, precision=2,
        past_tense="expired {} ago",
        future_tense="in {}",
    )


@view_config(route_name="auction_page", renderer="auction.j2")
def auction_page(request):
    auction = get_auction_by_id(
        request.dbsession, request.matchdict["auction_id"]
    )
    if auction is None:
        raise HTTPNotFound()
    return _serialize_auction(auction, request)


@view_config(route_name="auction_json", renderer="json")
def auction_json(request):
    auction = get_auction_by_id(
        request.dbsession, request.matchdict["auction_id"]
    )
    if auction is None:
        raise HTTPNotFound()
    return _serialize_auction(auction, request)


@view_config(route_name="auction_events")
def auction_events(request):
    """Bounded SSE feed of an auction's state (public — anyone may watch).

    Emits on bid / soft-close extension / state transition. The countdown
    is NOT part of the change signal (it would fire every poll) — the
    client derives it from `end_timestamp` locally. EventSource reconnects
    when the bounded stream closes.
    """
    from ..lib.sse import sse_response

    auction = get_auction_by_id(
        request.dbsession, request.matchdict["auction_id"]
    )
    if auction is None:
        raise HTTPNotFound()

    auction_id = auction.uuid_str

    def fetch_state(session):
        a = get_auction_by_id(session, auction_id)
        if a is None:
            return None
        bid_count = a.bids.count()
        version = (
            a.state, a.current_high_in_cents, a.end_timestamp, bid_count,
        )
        payload = {
            "id": auction_id,
            "state": a.state,
            "state_human": a.state_human,
            "is_active": a.is_active,
            "is_ended": a.is_ended,
            "is_terminal": a.is_terminal,
            "current_high_in_cents": a.current_high_in_cents,
            "current_high": a.current_high,
            "min_next_bid_in_cents": a.min_next_bid_in_cents,
            "min_next_bid": a.min_next_bid,
            "end_timestamp": a.end_timestamp,
            "time_remaining_ms": a.time_remaining_ms,
            "bid_count": bid_count,
            "has_reserve": a.has_reserve,
            "reserve_met": a.reserve_met,
            "winner_user_id": a.winner.uuid_str if a.winner else None,
        }
        return version, payload

    return sse_response(request, fetch_state)


@view_config(route_name="auction_bid", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to bid.")
def auction_bid(request):
    auction = get_auction_by_id(
        request.dbsession, request.matchdict["auction_id"]
    )
    if auction is None:
        raise HTTPNotFound()
    auction_url = f"/a/{auction.uuid_str}"

    def _err(status, msg):
        if _is_ajax(request):
            request.response.status_int = status
            return {"error": msg}
        request.session.flash((msg, "error"))
        return HTTPFound(auction_url)

    if _user_is_seller(request, auction):
        return _err(403, "you cannot bid on your own auction")

    try:
        amount_dollars = (request.params.get("amount") or "").strip()
        amount_in_cents = int(round(float(amount_dollars) * 100))
    except (TypeError, ValueError):
        return _err(400, "Enter a valid bid amount.")

    max_proxy_dollars = (request.params.get("max_proxy") or "").strip()
    max_proxy_in_cents = None
    if max_proxy_dollars:
        try:
            max_proxy_in_cents = int(round(float(max_proxy_dollars) * 100))
        except (TypeError, ValueError):
            return _err(400, "Enter a valid max proxy amount.")

    # Capture the prior winning bidder before place_bid mutates state,
    # so we know who to notify if they got outbid.
    prior_winning = (
        auction.bids.filter_by(is_winning=True).one_or_none()
    )
    prior_bidder = prior_winning.bidder if prior_winning else None

    try:
        bid = place_bid(
            auction=auction,
            bidder=request.user,
            amount_in_cents=amount_in_cents,
            max_proxy_in_cents=max_proxy_in_cents,
        )
    except BidRejected as e:
        return _err(400, str(e))

    # If the new bid took the lead and there was a different prior bidder,
    # send them an outbid email. Email failure must not break the bid
    # response — the bid is already persisted.
    if (
        bid.is_winning
        and prior_bidder is not None
        and prior_bidder != request.user
    ):
        try:
            send_auction_outbid_email(request, prior_bidder.email, auction)
        except Exception:
            log = logging.getLogger(__name__)
            log.exception("auction outbid email failed (non-fatal)")

    if _is_ajax(request):
        return {
            "ok": True,
            "bid_amount_in_cents": bid.amount_in_cents,
            "bid_amount": cents_to_dollars(bid.amount_in_cents),
            "is_winning": bid.is_winning,
            "auction_state": _serialize_auction(auction, request),
        }
    if bid.is_winning:
        request.session.flash(
            (f"Bid placed at ${bid.amount_in_cents / 100:.2f} — you're winning!", "success")
        )
    else:
        request.session.flash(
            ("Bid placed, but a proxy bid from another bidder still leads.", "error")
        )
    return HTTPFound(auction_url)


@view_config(route_name="auction_buy_now", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to buy.")
def auction_buy_now(request):
    """Buy-now ends the auction immediately. Mode 2 (auction+buy_now) only."""
    auction = get_auction_by_id(
        request.dbsession, request.matchdict["auction_id"]
    )
    if auction is None:
        raise HTTPNotFound()
    auction_url = f"/a/{auction.uuid_str}"

    def _err(status, msg):
        if _is_ajax(request):
            request.response.status_int = status
            return {"error": msg}
        request.session.flash((msg, "error"))
        return HTTPFound(auction_url)

    if not auction.has_buy_now:
        if _is_ajax(request):
            request.response.status_int = 404
            return {"error": "buy now not available on this auction"}
        request.session.flash(("Buy-now is not available on this auction.", "error"))
        return HTTPFound(auction_url)
    if not auction.is_active:
        return _err(400, "auction not active")
    if _user_is_seller(request, auction):
        return _err(403, "you cannot buy your own auction")

    # Place a bid at the buy_now price (system-driven), end the auction.
    try:
        bid = place_bid(
            auction=auction,
            bidder=request.user,
            amount_in_cents=auction.buy_now_price_in_cents,
            max_proxy_in_cents=auction.buy_now_price_in_cents,
        )
    except BidRejected as e:
        return _err(400, str(e))

    # End the auction immediately.
    auction.state = AUCTION_STATE_ENDED
    auction.end_timestamp = now_timestamp()
    auction.winner = request.user
    auction.winning_bid_id = bid.id
    request.dbsession.flush()

    if _is_ajax(request):
        return {
            "ok": True,
            "winning_bid_id": str(bid.id),
            "auction_state": _serialize_auction(auction, request),
        }
    request.session.flash(("Auction won — proceed to checkout.", "success"))
    return HTTPFound(auction_url)


@view_config(route_name="auction_watch", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to watch.")
def auction_watch(request):
    """Toggle: if watcher row exists, delete it; else create it."""
    auction = get_auction_by_id(
        request.dbsession, request.matchdict["auction_id"]
    )
    if auction is None:
        raise HTTPNotFound()
    auction_url = f"/a/{auction.uuid_str}"

    existing = (
        request.dbsession.query(MpsAuctionWatcher)
        .filter_by(auction_id=auction.id, user_id=request.user.id)
        .one_or_none()
    )
    if existing is not None:
        request.dbsession.delete(existing)
        request.dbsession.flush()
        if _is_ajax(request):
            return {"watching": False}
        request.session.flash(("No longer watching this auction.", "success"))
        return HTTPFound(auction_url)

    watcher = MpsAuctionWatcher(auction=auction, user=request.user)
    request.dbsession.add(watcher)
    request.dbsession.flush()
    if _is_ajax(request):
        return {"watching": True}
    request.session.flash(("Watching this auction — we'll email you on outbid and ending-soon.", "success"))
    return HTTPFound(auction_url)


@view_config(route_name="auction_checkout", request_method="POST")
@user_required(flash_msg="Please log in to pay.")
def auction_checkout(request):
    """Winner checkout: build a cart linked to the auction, redirect to /cart.

    Cart total is overridden to auction.winning_bid amount via the
    cart_auction association (Cart.auction_offer_override_in_cents).
    """
    from ..models.cart_auction import MpsCartAuction

    auction = get_auction_by_id(
        request.dbsession, request.matchdict["auction_id"]
    )
    if auction is None:
        raise HTTPNotFound()
    if auction.is_active or auction.is_draft or auction.is_scheduled:
        request.session.flash(("Auction is not yet ended.", "error"))
        return HTTPFound(f"/a/{auction.uuid_str}")
    if auction.is_settled:
        request.session.flash(("Auction has already been paid.", "error"))
        return HTTPFound(f"/a/{auction.uuid_str}")
    if auction.is_cancelled:
        request.session.flash(("Auction was cancelled.", "error"))
        return HTTPFound(f"/a/{auction.uuid_str}")
    if auction.winner is None or auction.winner != request.user:
        request.session.flash(("Only the auction winner can check out.", "error"))
        return HTTPFound(f"/a/{auction.uuid_str}")

    # Build a fresh active cart for this auction (create_new_cart_for_user
    # deactivates the user's other carts for this shop and activates this
    # one — a hand-set cart.active = True left two active carts and /cart
    # resolved to the wrong, empty one). The product is added with
    # quantity=auction.quantity so update_inventory() deducts the right
    # number of units; cart total comes from the cart_auction override.
    cart = auction.shop.create_new_cart_for_user(request.user)
    cart.add_product(auction.product)
    if auction.quantity > 1:
        cart.set_product_quantity(auction.product, auction.quantity)
    request.dbsession.add(
        MpsCartAuction(cart=cart, auction=auction)
    )
    request.dbsession.flush()
    return HTTPFound(f"/cart/{cart.uuid_str}")
