"""Advertisement service layer.

All advertisement business logic lives here: fetching the eligible advertisements
for a placement (respecting active status and the optional schedule window),
choosing one by **weighted random selection**, and recording impressions and
clicks. Routes and the template context processor stay thin and call into these
functions.

Selection strategy: the ``priority`` column is the admin-facing **Display Weight**
— it does *not* order advertisements. From the eligible pool one advertisement is
chosen at random with a probability proportional to its weight, so every eligible
advertisement can appear and none is always shown first, while administrators can
still influence how often each is served. Both the banner/homepage selector and
the rewarded selector share the one :func:`select_weighted` implementation.

The system is intentionally data-driven — nothing here references specific
advertisers, so new promotions are added purely as database rows.
"""

from __future__ import annotations

import random

from app.extensions import db
from app.models import Advertisement
from app.models.base import utcnow
from app.models.enums import AdvertisementPlacement, AdvertisementType

# Weight assumed for an advertisement whose weight is somehow unset. Mirrors the
# admin default (``DEFAULT_AD_PRIORITY``); the column is NOT NULL so this is only
# a defensive fallback.
DEFAULT_DISPLAY_WEIGHT = 100


def _placement_enum(placement) -> AdvertisementPlacement | None:
    """Coerce a placement string/enum to :class:`AdvertisementPlacement`."""
    if isinstance(placement, AdvertisementPlacement):
        return placement
    try:
        return AdvertisementPlacement(str(placement))
    except ValueError:
        return None


def _display_weight(ad: Advertisement) -> int:
    """Non-negative display weight for an advertisement (the ``priority`` column).

    Edge cases: a missing weight falls back to :data:`DEFAULT_DISPLAY_WEIGHT`; a
    negative weight is treated as ``0`` (i.e. ignored)."""
    weight = ad.priority
    if weight is None:
        return DEFAULT_DISPLAY_WEIGHT
    return max(int(weight), 0)


def select_weighted(ads: list[Advertisement]) -> Advertisement | None:
    """Choose one advertisement from ``ads`` by weighted random selection.

    Probability is proportional to each advertisement's display weight (the
    ``priority`` column). The single, shared selection primitive — used by both
    the banner/homepage and rewarded selectors so there is one weighting
    implementation.

    Edge cases (per the approved spec):

    * empty pool → ``None``;
    * exactly one advertisement → always that one;
    * a missing weight → :data:`DEFAULT_DISPLAY_WEIGHT`;
    * a negative weight → treated as ``0``;
    * **all** weights ``0`` (nothing to weight by) → every advertisement gets an
      equal chance, so a stray all-zero configuration never fails or hides ads.
    """
    if not ads:
        return None
    if len(ads) == 1:
        return ads[0]
    weights = [_display_weight(ad) for ad in ads]
    if sum(weights) <= 0:                 # all zero/negative → fall back to equal
        weights = [1] * len(ads)
    return random.choices(ads, weights=weights, k=1)[0]


def get_active_advertisements(
    placement, limit: int | None = None, *, ad_type: AdvertisementType | None = None
) -> list[Advertisement]:
    """Return the **eligible** advertisements for ``placement``.

    Eligibility = active status **and** the optional schedule window (``start_at``
    / ``end_at``). ``ad_type`` optionally restricts to one advertisement type —
    used by the rewarded selector so only rewarded advertisements are ever served
    from the shared ``CHAT`` placement. This is the shared eligibility query; the
    caller applies weighted selection (:func:`select_weighted`). The ordering here
    is a stable, deterministic listing only — it does **not** decide which
    advertisement is shown. Read-only: callers decide when to record impressions.
    Returns an empty list for an unknown placement so callers never need to guard.
    """
    placement_enum = _placement_enum(placement)
    if placement_enum is None:
        return []

    now = utcnow()
    conditions = [
        Advertisement.placement == placement_enum,
        Advertisement.is_active.is_(True),
        db.or_(Advertisement.start_at.is_(None), Advertisement.start_at <= now),
        db.or_(Advertisement.end_at.is_(None), Advertisement.end_at >= now),
    ]
    if ad_type is not None:
        conditions.append(Advertisement.advertisement_type == ad_type)
    query = (
        db.select(Advertisement)
        .where(*conditions)
        .order_by(Advertisement.priority.desc(), Advertisement.created_at.desc())
    )
    if limit is not None:
        query = query.limit(limit)
    return list(db.session.scalars(query))


def get_active_advertisement(placement) -> Advertisement | None:
    """Select one active advertisement for ``placement`` by weighted random
    selection (banner / homepage / sidebar single-ad slots).

    Every eligible advertisement can appear, with a probability proportional to
    its display weight (``priority``) — so the same advertisement never always
    appears first, while administrators still influence how often each is shown.
    Reuses the shared eligibility query and the shared :func:`select_weighted`
    primitive (no extra query, no duplicated logic). Returns ``None`` when nothing
    is eligible.
    """
    return select_weighted(get_active_advertisements(placement))


def get_rewarded_ad() -> Advertisement | None:
    """Select an eligible rewarded advertisement (the ``CHAT`` placement).

    Rewarded advertisements live in the ``CHAT`` placement — the single home of the
    "watch an ad to unlock messages" flow (there is no separate ``rewarded``
    placement). This reuses the shared :func:`get_active_advertisements` eligibility
    rules (active status + optional schedule window) scoped to ``CHAT`` **and** the
    ``REWARDED`` type, so a banner can never leak into the rewarded flow — a single
    query, no extra round-trips. From that eligible pool one advertisement is chosen
    by :func:`select_weighted` — the same weighted-random primitive the banner
    selector uses — so every eligible ad can appear and higher-weight ads appear
    proportionally more often, with no advertisement always served first.

    ``priority`` is the admin-facing Display Weight. Weight handling follows
    :func:`select_weighted` (negative → 0, all-zero → equal chance). Returns
    ``None`` when no advertisement is eligible, letting the rewarded modal fall
    back to its built-in placeholder without ever failing.

    Centralising selection here keeps callers (token mint, analytics) unchanged and
    leaves room for future rules — frequency caps, campaign scheduling — in one
    place.
    """
    ads = get_active_advertisements(
        AdvertisementPlacement.CHAT, ad_type=AdvertisementType.REWARDED
    )
    return select_weighted(ads)


def get_active_rewarded_ad() -> Advertisement | None:
    """Backwards-compatible alias for :func:`get_rewarded_ad`."""
    return get_rewarded_ad()


def get_active_rewarded_ad_by_public_id(public_id: str | None) -> Advertisement | None:
    """Return a specific active, in-window rewarded advertisement by public id.

    Used at reward-claim time to resolve the exact advertisement a signed reward
    token was issued for and confirm it still exists and is eligible. Scoped to the
    ``CHAT`` placement and the ``REWARDED`` type (the rewarded flow's home) so a
    token can only ever resolve a genuine rewarded advertisement. Returns ``None``
    when the id is empty or no matching eligible ad exists.
    """
    if not public_id:
        return None
    now = utcnow()
    return db.session.scalar(
        db.select(Advertisement).where(
            Advertisement.public_id == public_id,
            Advertisement.placement == AdvertisementPlacement.CHAT,
            Advertisement.advertisement_type == AdvertisementType.REWARDED,
            Advertisement.is_active.is_(True),
            db.or_(Advertisement.start_at.is_(None), Advertisement.start_at <= now),
            db.or_(Advertisement.end_at.is_(None), Advertisement.end_at >= now),
        )
    )


def record_impressions(ads) -> None:
    """Increment the impression counter for the given advertisements.

    Best-effort: a counter update must never break page rendering, so failures
    are swallowed after a rollback.
    """
    ids = [ad.id for ad in ads if getattr(ad, "id", None) is not None]
    if not ids:
        return
    try:
        db.session.execute(
            db.update(Advertisement)
            .where(Advertisement.id.in_(ids))
            .values(impression_count=Advertisement.impression_count + 1)
        )
        db.session.commit()
    except Exception:  # pragma: no cover - analytics must not break the page
        db.session.rollback()


def get_advertisement_or_404(public_id: str) -> Advertisement:
    """Return an advertisement by public id, or abort with 404."""
    return db.first_or_404(
        db.select(Advertisement).where(Advertisement.public_id == public_id)
    )


def record_click(ad: Advertisement) -> None:
    """Increment the click counter for an advertisement."""
    ad.click_count = (ad.click_count or 0) + 1
    db.session.commit()
