"""Offer views — MPS-21.

Routes:
    POST /p/{product_id}/offer    open a new offer (login required)
    GET  /o/{offer_id}            offer detail page (buyer + seller + admin)
    POST /o/{offer_id}/counter    counter the current amount
    POST /o/{offer_id}/accept     accept current amount (terminal)
    POST /o/{offer_id}/decline    decline current amount (terminal)
    POST /o/{offer_id}/withdraw   buyer pulls offer (terminal)

Identity resolution lives in this module: the pure logic in lib/offer.py
takes actor_party (BUYER vs SELLER) and does not know who owns the shop.
View handlers translate request.user into a party and reject self-offers
or third-party access.
"""

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

import logging

from ..lib.offer import (
    OfferRejected,
    open_offer,
    counter_offer,
    accept_offer,
    decline_offer,
    withdraw_offer,
    cancel_offer_after_accept,
)
from ..lib.mail import (
    send_offer_received_email,
    send_offer_accepted_email,
)
from ..lib.currency import cents_to_dollars
from ..models.offer import (
    MpsOffer,
    OFFER_PARTY_BUYER,
    OFFER_PARTY_SELLER,
    OFFER_STATE_DECLINED,
    OFFER_STATE_WITHDRAWN,
    OFFER_EVENT_INT_TO_HUMAN,
    get_offer_by_id,
)
from ..models.product import get_product_by_id
from ..views import user_required, shop_editor_required


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


def _respond(request, payload, redirect_url, flash_msg=None, flash_level="success"):
    """Return JSON for AJAX callers; otherwise flash + redirect so a plain
    browser POST lands on a real page instead of seeing raw JSON."""
    if _is_ajax(request):
        return payload
    if flash_msg:
        request.session.flash((flash_msg, flash_level))
    return HTTPFound(redirect_url)


def _user_party(request, offer):
    """Return (party_int, can_view).

    party_int: OFFER_PARTY_BUYER if user is the buyer,
               OFFER_PARTY_SELLER if user is in shop.owners,
               None if neither.
    can_view: True for buyer or seller; False otherwise.
    """
    user = request.user
    if user is None:
        return (None, False)
    if user == offer.buyer:
        return (OFFER_PARTY_BUYER, True)
    if user in offer.shop.owners:
        return (OFFER_PARTY_SELLER, True)
    return (None, False)


def _serialize_offer(offer):
    return {
        "id": offer.uuid_str,
        "product_id": offer.product.uuid_str,
        "product_title": offer.product.title,
        "shop_id": offer.shop.uuid_str,
        "buyer_id": offer.buyer.uuid_str,
        "buyer_name": offer.buyer.display_name,
        "buyer_handle": offer.buyer.name,
        "state": offer.state,
        "state_human": offer.state_human,
        "is_open": offer.is_open,
        "is_terminal": offer.is_terminal,
        "is_paid": offer.is_paid,
        "is_pending": offer.is_pending,
        "is_countered": offer.is_countered,
        "is_accepted": offer.is_accepted,
        "is_expired": offer.is_expired,
        "is_declined": offer.state == OFFER_STATE_DECLINED,
        "is_withdrawn": offer.state == OFFER_STATE_WITHDRAWN,
        "current_amount_in_cents": offer.current_amount_in_cents,
        "current_amount": offer.current_amount,
        "current_party": offer.current_party,
        "round_count": offer.round_count,
        "expires_timestamp": offer.expires_timestamp,
        "time_remaining_ms": offer.time_remaining_ms,
        "buyer_message": offer.buyer_message,
        "seller_message": offer.seller_message,
        "events": [
            {
                "event_type": e.event_type,
                "event_human": OFFER_EVENT_INT_TO_HUMAN.get(
                    e.event_type, "Unknown"
                ),
                "actor_id": e.actor.uuid_str if e.actor else None,
                "actor_name": e.actor.display_name if e.actor else None,
                "actor_handle": e.actor.name if e.actor else None,
                "amount_in_cents": e.amount_in_cents,
                "amount": (
                    cents_to_dollars(e.amount_in_cents)
                    if e.amount_in_cents is not None
                    else None
                ),
                "message": e.message,
                "created_timestamp": e.created_timestamp,
            }
            for e in offer.events
        ],
    }


@view_config(route_name="offer_open", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to make an offer.")
def offer_open(request):
    product = get_product_by_id(
        request.dbsession, request.matchdict["product_id"]
    )
    if product is None:
        raise HTTPNotFound()
    product_url = product.absolute_url(request)

    if not product.offers_allowed:
        if _is_ajax(request):
            request.response.status_int = 403
            return {"error": "this product is not accepting offers"}
        request.session.flash(("This product is not accepting offers.", "error"))
        return HTTPFound(product_url)
    # Block self-offer.
    if request.user in product.shop.owners:
        if _is_ajax(request):
            request.response.status_int = 403
            return {"error": "you cannot offer on your own product"}
        request.session.flash(("You cannot offer on your own product.", "error"))
        return HTTPFound(product_url)

    try:
        amount_in_cents = int(round(float(request.params.get("amount", "")) * 100))
    except (TypeError, ValueError):
        if _is_ajax(request):
            request.response.status_int = 400
            return {"error": "invalid amount"}
        request.session.flash(("Enter a valid offer amount.", "error"))
        return HTTPFound(product_url)

    buyer_message = (request.params.get("message") or "").strip() or None

    try:
        offer = open_offer(
            request.dbsession,
            product=product,
            shop=product.shop,
            buyer=request.user,
            amount_in_cents=amount_in_cents,
            buyer_message=buyer_message,
        )
    except OfferRejected as e:
        if _is_ajax(request):
            request.response.status_int = 400
            return {"error": str(e)}
        request.session.flash((str(e), "error"))
        return HTTPFound(product_url)

    # Send email notification — pending offers go to seller; auto-accept
    # offers go to buyer (so they know to pay). Auto-decline offers are
    # silent — seller never sees the lowball.
    try:
        if offer.is_pending:
            for owner in product.shop.owners:
                send_offer_received_email(request, owner.email, offer)
        elif offer.is_accepted:
            send_offer_accepted_email(request, offer.buyer.email, offer)
    except Exception:
        logging.getLogger(__name__).exception(
            "offer open email failed (non-fatal)"
        )

    # Flash message tuned to the offer's resolved state.
    if offer.is_accepted:
        flash = ("Offer accepted! Pay now to complete the purchase.", "success")
    elif offer.state == OFFER_STATE_DECLINED:  # auto-decline threshold
        flash = (
            "Your offer was below the seller's minimum and was automatically "
            "declined. Try a higher amount.",
            "error",
        )
    else:  # pending — seller will review
        flash = ("Offer submitted. The seller will review it.", "success")

    payload = {
        "ok": True,
        "offer_id": offer.uuid_str,
        "offer": _serialize_offer(offer),
    }
    return _respond(
        request, payload, f"/o/{offer.uuid_str}",
        flash_msg=flash[0], flash_level=flash[1],
    )


@view_config(route_name="offer_page", renderer="offer.j2")
def offer_page(request):
    offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
    if offer is None:
        raise HTTPNotFound()

    actor_party, can_view = _user_party(request, offer)
    if not can_view:
        if request.user is None:
            return HTTPFound(
                f"/join-or-log-in?next=/o/{offer.uuid_str}"
            )
        raise HTTPNotFound()

    ctx = _serialize_offer(offer)
    ctx["actor_party"] = actor_party
    ctx["can_act"] = (
        actor_party is not None
        and offer.is_open
        and actor_party == offer.current_party
    )

    deadline = offer.acceptance_pay_deadline_ms
    if deadline is not None:
        import ago
        from datetime import datetime, timezone
        deadline_dt = datetime.fromtimestamp(deadline / 1000, tz=timezone.utc)
        # Human delta form ("in 23 hours, 13 minutes") instead of a UTC
        # wall-clock string. JS refines this to second precision on
        # capable browsers; this is the no-JS fallback.
        ctx["pay_deadline_human"] = ago.human(
            deadline_dt, precision=2,
            past_tense="expired {} ago",
            future_tense="in {}",
        )
        ctx["pay_deadline_timestamp_ms"] = deadline
    else:
        ctx["pay_deadline_human"] = None
        ctx["pay_deadline_timestamp_ms"] = None
    return ctx


@view_config(route_name="offer_events")
def offer_events(request):
    """Bounded SSE feed of an offer's state-machine state.

    Only the buyer or a shop owner may subscribe (offers are private).
    The browser EventSource reconnects when the bounded stream closes.
    """
    from ..lib.sse import sse_response

    offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
    if offer is None:
        raise HTTPNotFound()
    _party, can_view = _user_party(request, offer)
    if not can_view:
        raise HTTPNotFound()

    offer_id = offer.uuid_str

    def fetch_state(session):
        o = get_offer_by_id(session, offer_id)
        if o is None:
            return None
        version = (
            o.state, o.current_amount_in_cents, o.round_count,
            o.last_action_timestamp,
        )
        payload = {
            "id": offer_id,
            "state": o.state,
            "state_human": o.state_human,
            "current_amount_in_cents": o.current_amount_in_cents,
            "current_amount": o.current_amount,
            "round_count": o.round_count,
            "current_party": o.current_party,
            "is_open": o.is_open,
            "is_terminal": o.is_terminal,
            "is_pending": o.is_pending,
            "is_countered": o.is_countered,
            "is_accepted": o.is_accepted,
            "is_expired": o.is_expired,
            "is_declined": o.state == OFFER_STATE_DECLINED,
            "is_withdrawn": o.state == OFFER_STATE_WITHDRAWN,
            "is_paid": o.is_paid,
        }
        return version, payload

    return sse_response(request, fetch_state)


def _offer_action(request, action_fn, message_required=False):
    """Common handling for counter / accept / decline / withdraw.

    AJAX callers get JSON; plain browser POSTs get flash + redirect to
    the offer detail page."""
    offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
    if offer is None:
        raise HTTPNotFound()
    offer_url = f"/o/{offer.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(offer_url)

    actor_party, can_view = _user_party(request, offer)
    if not can_view or actor_party is None:
        return _err(403, "not authorized")

    message = (request.params.get("message") or "").strip() or None

    try:
        if action_fn is counter_offer:
            try:
                new_amount = int(round(float(
                    request.params.get("amount", "")) * 100))
            except (TypeError, ValueError):
                return _err(400, "Enter a valid counter amount.")
            action_fn(
                offer, request.user, actor_party, new_amount, message=message,
            )
            done_msg = f"Countered at ${new_amount / 100:.2f}."
        elif action_fn is withdraw_offer:
            if actor_party != OFFER_PARTY_BUYER:
                return _err(403, "only the buyer can withdraw")
            action_fn(offer, request.user, message=message)
            done_msg = "Offer withdrawn."
        elif action_fn is cancel_offer_after_accept:
            if actor_party != OFFER_PARTY_BUYER:
                return _err(403, "only the buyer can cancel an accepted offer")
            action_fn(offer, request.user, message=message)
            done_msg = "Offer cancelled."
        elif action_fn is accept_offer:
            action_fn(offer, request.user, actor_party, message=message)
            done_msg = "Offer accepted."
        elif action_fn is decline_offer:
            action_fn(offer, request.user, actor_party, message=message)
            done_msg = "Offer declined."
        else:
            action_fn(offer, request.user, actor_party, message=message)
            done_msg = "Done."
    except OfferRejected as e:
        return _err(400, str(e))

    if _is_ajax(request):
        return {"ok": True, "offer": _serialize_offer(offer)}
    request.session.flash((done_msg, "success"))
    return HTTPFound(offer_url)


@view_config(route_name="offer_counter", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to act on this offer.")
def offer_counter(request):
    return _offer_action(request, counter_offer)


@view_config(route_name="offer_accept", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to act on this offer.")
def offer_accept(request):
    offer_before = get_offer_by_id(
        request.dbsession, request.matchdict["offer_id"]
    )
    was_accepted = offer_before is not None and offer_before.is_accepted

    result = _offer_action(request, accept_offer)

    # Email the buyer only on the transition into ACCEPTED (not on a
    # repeated request against an already-accepted offer).
    offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
    if offer is not None and offer.is_accepted and not was_accepted:
        try:
            send_offer_accepted_email(request, offer.buyer.email, offer)
        except Exception:
            logging.getLogger(__name__).exception(
                "offer accepted email failed (non-fatal)"
            )
    return result


@view_config(route_name="offer_decline", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to act on this offer.")
def offer_decline(request):
    return _offer_action(request, decline_offer)


@view_config(route_name="offer_withdraw", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to act on this offer.")
def offer_withdraw(request):
    return _offer_action(request, withdraw_offer)


@view_config(
    route_name="offer_cancel_after_accept",
    request_method="POST",
    renderer="json",
)
@user_required(flash_msg="Please log in to act on this offer.")
def offer_cancel_after_accept_view(request):
    """Buyer back-out *after* the offer was accepted. Different terminal
    state than withdraw so the seller's inbox can distinguish "they
    pulled it mid-negotiation" from "they ghosted after I accepted."
    """
    return _offer_action(request, cancel_offer_after_accept)


@view_config(route_name="offer_checkout", request_method="POST")
@user_required(flash_msg="Please log in to pay.")
def offer_checkout(request):
    """Buyer checkout for an accepted offer: build (or reuse) a cart
    linked to the offer and redirect into it. Cart total is overridden
    to the agreed amount via the cart_offer association.

    Single-redemption: an accepted offer maps to at most ONE cart. If
    the buyer revisits /o/{id}/checkout after a cart was already opened,
    we redirect them into that same cart instead of spawning a fresh
    one — otherwise the buyer could create N parallel carts on a single
    offer and try to pay each.
    """
    from ..models.cart_offer import MpsCartOffer
    from ..models.offer import OFFER_STATE_ACCEPTED

    offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
    if offer is None:
        raise HTTPNotFound()
    if offer.state != OFFER_STATE_ACCEPTED:
        request.session.flash(("Only accepted offers can be paid.", "error"))
        return HTTPFound(f"/o/{offer.uuid_str}")
    if request.user != offer.buyer:
        request.session.flash(("Only the buyer can check out.", "error"))
        return HTTPFound(f"/o/{offer.uuid_str}")

    # Reuse-or-create. One offer → one cart_offer → one cart, ever.
    existing = (
        request.dbsession.query(MpsCartOffer)
        .filter(MpsCartOffer.offer_id == offer.id)
        .first()
    )
    if existing is not None:
        cart = existing.cart
        # Make sure this cart is active (the buyer's most recent
        # interaction makes it the one /cart resolves to).
        offer.shop.make_cart_active_for_user(request.user, cart)
        return HTTPFound(f"/cart/{cart.uuid_str}")

    # First-time checkout for this offer — spawn the cart.
    cart = offer.shop.create_new_cart_for_user(request.user)
    cart.add_product(offer.product)
    request.dbsession.add(MpsCartOffer(cart=cart, offer=offer))
    request.dbsession.flush()
    return HTTPFound(f"/cart/{cart.uuid_str}")


@view_config(route_name="shop_offers", renderer="shop_offers.j2")
@shop_editor_required()
def shop_offers(request):
    """Operator inbox of all make-an-offer negotiations for this shop.

    Open offers (pending / countered) sort to the top, most-recently
    active first; terminal offers follow. Each row links to /o/{id}.
    """
    shop = request.shop
    offers = (
        request.dbsession.query(MpsOffer)
        .filter(MpsOffer.shop_id == shop.id)
        .all()
    )
    offers.sort(key=lambda o: (o.is_terminal, -(o.last_action_timestamp or 0)))

    from datetime import datetime, timezone

    def _human(ts):
        if not ts:
            return ""
        return datetime.fromtimestamp(ts / 1000, tz=timezone.utc).strftime(
            "%Y-%m-%d %H:%M UTC"
        )

    rows = []
    for o in offers:
        rows.append({
            "id": o.uuid_str,
            "product_title": o.product.title if o.product else "(removed product)",
            "buyer_name": o.buyer.display_name if o.buyer else "(unknown)",
            "buyer_handle": o.buyer.name if o.buyer else None,
            "state": o.state,
            "state_human": o.state_human,
            "is_open": o.is_open,
            "is_terminal": o.is_terminal,
            "current_amount": o.current_amount,
            "round_count": o.round_count,
            "last_action_human": _human(o.last_action_timestamp),
            "waiting_on_seller": (
                o.is_open and o.current_party == OFFER_PARTY_SELLER
            ),
        })
    return {
        "the_title": "Shop Offers",
        "shop": shop,
        "offers": rows,
        "open_count": sum(1 for r in rows if not r["is_terminal"]),
    }
