"""Smart discovery & recommendation engine (Phase 8, curated redesign).

Centralises every ranking / recommendation computation that powers the
marketplace homepage and the "similar listings" section. All scoring logic lives
here — templates only render the returned listings, never rank them.

Each homepage section answers a distinct question and uses its OWN dedicated
query / ranking — no section reuses another's:

  * Trending      → recent momentum (recent views + engagement, recency, minus a
                    moderation penalty). Recency matters more than lifetime totals.
  * Popular       → overall / historical engagement (lifetime views + engagement).
  * New arrivals  → genuinely new listings only (created in the last N days).
  * Featured      → seller-promoted listings only (``is_featured``), newest-featured.
  * Recommended   → personalised to the viewer (saved + browsed categories, price
                    band, campus/location) with sensible non-random fallbacks.
  * Recently view → the current (logged-in) user's own browsing history.

Duplicate prevention: the homepage builds sections in a priority order
(Featured → Trending → Popular → Recommended → New arrivals) and skips a listing
in a lower-priority section when it already appears higher up — unless the catalog
is too small, where duplicates are an accepted graceful fallback.

Performance: each section is bounded (fixed limits / capped candidate pools),
per-listing signals are resolved with GROUP BY aggregation or cached counter
columns (never one query per card), images / categories are eager-loaded via
``selectinload``, and the query COUNT is constant regardless of catalog size.
"""

from __future__ import annotations

from datetime import timedelta

from sqlalchemy import case, func
from sqlalchemy.orm import selectinload

from app.extensions import db
from app.marketplace.services import cleanup_expired_featured_listings
from app.models import (
    Category,
    Conversation,
    Favorite,
    Listing,
    ListingStatus,
    ListingView,
    ListingVisibility,
)
from app.models.base import utcnow
from app.moderation.services import get_open_report_listing_ids

# --- Ranking weights (all tunable in one place) ----------------------------
# Trending — recent momentum: recent views + engagement.
W_TREND_VIEW = 1
W_TREND_FAVORITE = 3
W_TREND_CHAT = 5
W_TREND_OFFER = 8
# Popular — overall / historical engagement.
W_POP_VIEW = 1
W_POP_FAVORITE = 2
W_POP_CHAT = 4
W_POP_OFFER = 6
# Quality guard (trending only): open-reported listings sink.
W_REPORT_PENALTY = 20

# Windows / pools.
TRENDING_VIEW_WINDOW_DAYS = 7   # "recent" window for trending views
NEW_ARRIVALS_WINDOW_DAYS = 14   # only listings this fresh count as "new"
SCORING_POOL = 200             # cap the trending candidate set (bounded work)
RECENT_HISTORY_SAMPLE = 50     # how much saved/viewed history informs recommendations
SIMILAR_PRICE_BAND = 0.4       # ±40% price window for "similar"
RECOMMEND_PRICE_BAND = 0.6     # ±60% price window for recommendations

# Homepage section size + how many extra candidates to fetch for de-duplication.
SECTION_SIZE = 8
DEDUP_OVERFETCH = 3

# Default limits for the public helpers (also used directly / in tests).
TRENDING_LIMIT = SECTION_SIZE
FEATURED_LIMIT = SECTION_SIZE
NEW_ARRIVALS_LIMIT = SECTION_SIZE
POPULAR_LIMIT = SECTION_SIZE
RECOMMENDED_LIMIT = SECTION_SIZE
RECENTLY_VIEWED_LIMIT = SECTION_SIZE
CATEGORIES_LIMIT = 12
SIMILAR_LIMIT = 6


def _available_clause():
    """Predicate list for a publicly browseable listing.

    Excludes sold / reserved / inactive (anything not ``AVAILABLE``), non-public
    and soft-deleted listings — so no section ever surfaces a stale card.
    """
    return (
        Listing.status == ListingStatus.AVAILABLE,
        Listing.visibility == ListingVisibility.PUBLIC,
        Listing.deleted_at.is_(None),
    )


def _eager():
    return (selectinload(Listing.images), selectinload(Listing.category))


def _grouped_counts(id_col, ids, *, model, extra=None) -> dict[int, int]:
    """One GROUP BY count keyed by listing id, restricted to ``ids``."""
    where = [id_col.in_(ids)]
    if extra is not None:
        where.append(extra)
    rows = db.session.execute(
        db.select(id_col, func.count()).select_from(model).where(*where).group_by(id_col)
    ).all()
    return {lid: int(c) for lid, c in rows}


# --- 1. Trending (recent momentum) -----------------------------------------

def get_trending_listings(limit: int = TRENDING_LIMIT) -> list[Listing]:
    """Highest recent-momentum listings.

    Score = recent views (last ``TRENDING_VIEW_WINDOW_DAYS`` days) + favorites×3 +
    chats×5 + offers×8, minus a penalty for open-reported listings. It is driven
    by *recent engagement*, not by how new or how historically popular a listing
    is — so a brand-new listing with no activity belongs in New arrivals, and a
    lifetime favourite belongs in Popular, keeping the sections distinct. Scored
    over a capped candidate pool so the work stays bounded regardless of catalog
    size.
    """
    now = utcnow()
    candidates = db.session.scalars(
        db.select(Listing).options(*_eager())
        .where(*_available_clause())
        .order_by(Listing.created_at.desc())
        .limit(SCORING_POOL)
    ).all()
    if not candidates:
        return []
    ids = [l.id for l in candidates]

    view_cutoff = now - timedelta(days=TRENDING_VIEW_WINDOW_DAYS)
    recent_views = _grouped_counts(ListingView.listing_id, ids,
                                   extra=(ListingView.viewed_at >= view_cutoff), model=ListingView)
    chats = _grouped_counts(Conversation.listing_id, ids, model=Conversation)
    reported = get_open_report_listing_ids(ids)

    scored = [(_trending_score(l, recent_views, chats, reported), l) for l in candidates]
    scored.sort(key=lambda pair: (pair[0], pair[1].created_at), reverse=True)
    return [listing for _score, listing in scored[:limit]]


def _trending_score(listing, recent_views, chats, reported) -> float:
    """Recent-momentum score (see the trending weights)."""
    return (
        recent_views.get(listing.id, 0) * W_TREND_VIEW
        + (listing.favorite_count or 0) * W_TREND_FAVORITE
        + chats.get(listing.id, 0) * W_TREND_CHAT
        + (listing.offer_count or 0) * W_TREND_OFFER
        - (W_REPORT_PENALTY if listing.id in reported else 0)
    )


# --- 2. Popular (overall engagement) ---------------------------------------

def get_popular_listings(limit: int = POPULAR_LIMIT) -> list[Listing]:
    """Highest overall-engagement listings.

    Score = lifetime views + favorites×2 + chats×4 + offers×6, ranked entirely in
    SQL (one bounded query): views / favorites / offers come from the cached
    counter columns and chats from a single grouped sub-query. Independent of
    Trending — this rewards consistent, historical popularity, not recency.
    """
    chat_counts = (
        db.select(Conversation.listing_id.label("lid"), func.count().label("chats"))
        .group_by(Conversation.listing_id)
        .subquery()
    )
    score = (
        Listing.view_count * W_POP_VIEW
        + Listing.favorite_count * W_POP_FAVORITE
        + func.coalesce(chat_counts.c.chats, 0) * W_POP_CHAT
        + Listing.offer_count * W_POP_OFFER
    )
    return list(db.session.scalars(
        db.select(Listing).options(*_eager())
        .outerjoin(chat_counts, chat_counts.c.lid == Listing.id)
        .where(*_available_clause())
        .order_by(score.desc(), Listing.created_at.desc())
        .limit(limit)
    ).all())


# --- 3. New arrivals -------------------------------------------------------

def get_new_arrivals(limit: int = NEW_ARRIVALS_LIMIT) -> list[Listing]:
    """Genuinely-new listings only: created within ``NEW_ARRIVALS_WINDOW_DAYS``,
    newest first. Returns fewer than ``limit`` when there aren't enough recent
    listings — never padded with older stock."""
    cutoff = utcnow() - timedelta(days=NEW_ARRIVALS_WINDOW_DAYS)
    return db.session.scalars(
        db.select(Listing).options(*_eager())
        .where(*_available_clause(), Listing.created_at >= cutoff)
        .order_by(Listing.created_at.desc())
        .limit(limit)
    ).all()


# --- 4. Featured (seller-promoted only) ------------------------------------

def get_featured_discovery(limit: int = FEATURED_LIMIT) -> list[Listing]:
    """Seller-promoted listings only — never inferred.

    ``is_featured`` is the sole criterion (expired promotions are cleared to
    ``False`` by ``cleanup_expired_featured_listings`` before the homepage builds),
    ordered by ``featured_at`` DESC so the most recently promoted appear first.
    """
    return db.session.scalars(
        db.select(Listing).options(*_eager())
        .where(*_available_clause(), Listing.is_featured.is_(True))
        .order_by(Listing.featured_at.desc())
        .limit(limit)
    ).all()


def has_featured_in_system() -> bool:
    """Whether any promoted (``is_featured``) listing exists at all, regardless of
    availability.

    Lets the homepage distinguish "a Premium seller has featured something but it
    is momentarily unavailable" (show an informative empty state) from "nothing has
    ever been featured" (hide the section entirely). Cheap COUNT, no row load.
    """
    return (
        db.session.scalar(db.select(func.count(Listing.id)).where(Listing.is_featured.is_(True)))
        or 0
    ) > 0


# --- 5. Recommended for you ------------------------------------------------

def get_recommended_for_you(current_user, limit: int = RECOMMENDED_LIMIT,
                            trending_fallback=None) -> list[Listing]:
    """Personalised recommendations for a logged-in user; Trending for anonymous.

    Signal is drawn from the user's SAVED (favorited) and recently VIEWED
    listings: their categories, a price band around what they engage with, and
    their dominant campus/location. Ranking priority: saved-category first, then
    closer price, then same location, then engagement and freshness — never
    random. Falls back (still excluding own + already-seen items) to the same
    categories, then newest overall; anonymous / no-history users fall back to
    Trending.
    """
    uid = getattr(current_user, "id", None)
    if not getattr(current_user, "is_authenticated", False) or uid is None:
        return trending_fallback if trending_fallback is not None else get_trending_listings(limit)

    saved = db.session.execute(
        db.select(Listing.category_id, Listing.price, Listing.location, Listing.id)
        .join(Favorite, Favorite.listing_id == Listing.id)
        .where(Favorite.user_id == uid)
        .order_by(Favorite.created_at.desc())
        .limit(RECENT_HISTORY_SAMPLE)
    ).all()
    viewed = db.session.execute(
        db.select(Listing.category_id, Listing.price, Listing.location, ListingView.listing_id)
        .join(Listing, ListingView.listing_id == Listing.id)
        .where(ListingView.viewer_id == uid)
        .order_by(ListingView.viewed_at.desc())
        .limit(RECENT_HISTORY_SAMPLE)
    ).all()

    if not saved and not viewed:
        return trending_fallback if trending_fallback is not None else get_trending_listings(limit)

    saved_categories = {r[0] for r in saved if r[0] is not None}
    viewed_categories = {r[0] for r in viewed if r[0] is not None}
    categories = saved_categories | viewed_categories
    exclude_ids = {r[3] for r in saved} | {r[3] for r in viewed}
    prices = [float(r[1]) for r in (saved + viewed) if r[1] is not None]
    avg_price = sum(prices) / len(prices) if prices else None
    locations = [r[2] for r in (saved + viewed) if r[2]]
    dominant_location = max(set(locations), key=locations.count) if locations else None

    query = (
        db.select(Listing).options(*_eager())
        .where(*_available_clause(), Listing.seller_id != uid, Listing.id.notin_(exclude_ids))
    )
    if categories:
        query = query.where(Listing.category_id.in_(categories))
    if avg_price:
        query = query.where(
            Listing.price >= avg_price * (1 - RECOMMEND_PRICE_BAND),
            Listing.price <= avg_price * (1 + RECOMMEND_PRICE_BAND),
        )

    # Ranking priority: saved-category > closer price > same location > featured >
    # engagement > freshness.
    order = []
    if saved_categories:
        order.append(case((Listing.category_id.in_(saved_categories), 1), else_=0).desc())
    if avg_price:
        order.append(func.abs(Listing.price - avg_price).asc())
    if dominant_location:
        order.append(case((Listing.location == dominant_location, 1), else_=0).desc())
    order += [Listing.is_featured.is_(True).desc(), Listing.view_count.desc(), Listing.created_at.desc()]
    recs = list(db.session.scalars(query.order_by(*order).limit(limit)).all())

    # Fallback 1: broaden to the same categories (drop the price band).
    if len(recs) < limit and categories:
        have = {l.id for l in recs} | exclude_ids
        recs += list(db.session.scalars(
            db.select(Listing).options(*_eager())
            .where(*_available_clause(), Listing.seller_id != uid,
                   Listing.category_id.in_(categories), Listing.id.notin_(have))
            .order_by(Listing.created_at.desc())
            .limit(limit - len(recs))
        ).all())
    # Fallback 2: newest overall (never random).
    if len(recs) < limit:
        have = {l.id for l in recs} | exclude_ids
        recs += list(db.session.scalars(
            db.select(Listing).options(*_eager())
            .where(*_available_clause(), Listing.seller_id != uid, Listing.id.notin_(have))
            .order_by(Listing.created_at.desc())
            .limit(limit - len(recs))
        ).all())
    return recs[:limit]


# --- 6. Similar listings ---------------------------------------------------

def get_similar_listings(listing: Listing, limit: int = SIMILAR_LIMIT) -> list[Listing]:
    """Listings similar to ``listing``: same category, similar price, similar
    condition, recent activity. Excludes the listing itself; tops up with other
    same-category items so the section is never awkwardly empty."""
    price = float(listing.price) if listing.price is not None else None
    same_condition = case((Listing.condition == listing.condition, 1), else_=0)

    query = (
        db.select(Listing).options(*_eager())
        .where(
            *_available_clause(),
            Listing.id != listing.id,
            Listing.category_id == listing.category_id,
        )
    )
    if price:
        query = query.where(
            Listing.price >= price * (1 - SIMILAR_PRICE_BAND),
            Listing.price <= price * (1 + SIMILAR_PRICE_BAND),
        )
    similar = list(db.session.scalars(
        query.order_by(same_condition.desc(), Listing.view_count.desc(), Listing.created_at.desc()).limit(limit)
    ).all())

    if len(similar) < limit:
        have = {l.id for l in similar} | {listing.id}
        extra = db.session.scalars(
            db.select(Listing).options(*_eager())
            .where(*_available_clause(), Listing.category_id == listing.category_id, Listing.id.notin_(have))
            .order_by(Listing.created_at.desc())
            .limit(limit - len(similar))
        ).all()
        similar += list(extra)
    return similar[:limit]


# --- 7. Recently viewed ----------------------------------------------------

def get_recently_viewed(current_user, limit: int = RECENTLY_VIEWED_LIMIT) -> list[Listing]:
    """The current user's own browsing history — distinct listings, newest viewed
    first. Only for authenticated users: anonymous visitors get an empty list so
    the section is hidden. Returns ``[]`` when there is no history. Only
    currently-available listings are returned so every card is valid."""
    uid = getattr(current_user, "id", None)
    if not getattr(current_user, "is_authenticated", False) or uid is None:
        return []

    rows = db.session.execute(
        db.select(ListingView.listing_id, func.max(ListingView.viewed_at).label("last"))
        .where(ListingView.viewer_id == uid)
        .group_by(ListingView.listing_id)
        .order_by(func.max(ListingView.viewed_at).desc())
        .limit(limit * 3)
    ).all()
    if not rows:
        return []
    ordered_ids = [lid for lid, _ in rows]
    by_id = {
        l.id: l for l in db.session.scalars(
            db.select(Listing).options(*_eager()).where(*_available_clause(), Listing.id.in_(ordered_ids))
        ).all()
    }
    return [by_id[lid] for lid in ordered_ids if lid in by_id][:limit]


# --- 8. Browse categories --------------------------------------------------

def get_browse_categories(limit: int = CATEGORIES_LIMIT) -> list[dict]:
    """Top active categories by published (available) listing count."""
    rows = db.session.execute(
        db.select(Category, func.count(Listing.id))
        .join(Listing, Listing.category_id == Category.id)
        .where(*_available_clause(), Category.is_active.is_(True))
        .group_by(Category.id)
        .order_by(func.count(Listing.id).desc(), Category.display_order)
        .limit(limit)
    ).all()
    return [{"category": cat, "count": int(count)} for cat, count in rows]


# --- 9. Homepage aggregate (with cross-section de-duplication) -------------

def _dedup_take(candidates, used, size, *, fill_when_empty=True):
    """Take up to ``size`` listings from ``candidates`` that are not already in
    ``used``; record the chosen ids. If nothing unique remains, either return the
    top candidates (graceful duplicate fallback for tiny catalogs) or an empty
    list, per ``fill_when_empty``. Returns fewer than ``size`` when there simply
    aren't enough fresh items — sections are allowed to be short."""
    unique = [l for l in candidates if l.id not in used]
    chosen = unique[:size]
    if not chosen and fill_when_empty:
        chosen = list(candidates[:size])
    for l in chosen:
        used.add(l.id)
    return chosen


def get_homepage_sections(current_user) -> dict:
    """Build every homepage section in one bounded pass, with cross-section
    de-duplication in priority order Featured → Trending → Popular →
    Recommended → New arrivals.

    Trending is computed once (a small over-fetched pool) and reused as the
    anonymous / no-history recommendation fallback, so the scoring never runs
    twice. Recently viewed is independent (a personal history row) and is not
    de-duplicated against the ranked sections.
    """
    cleanup_expired_featured_listings()
    used: set[int] = set()
    over = SECTION_SIZE * DEDUP_OVERFETCH

    # Featured is the top priority: shown as-is, and reserves its listings.
    featured = get_featured_discovery(limit=SECTION_SIZE)
    for listing in featured:
        used.add(listing.id)

    trending_pool = get_trending_listings(limit=over)
    trending = _dedup_take(trending_pool, used, SECTION_SIZE)

    popular = _dedup_take(get_popular_listings(limit=over), used, SECTION_SIZE)

    # Recommended must feel personal: don't pad it with already-shown items. The
    # fallback for anonymous / no-history users is the (already-shown) Trending
    # section, which de-dup then empties — so the template's "browse to
    # personalise" prompt shows instead of echoing Trending's listings.
    recommendations = _dedup_take(
        get_recommended_for_you(current_user, limit=over, trending_fallback=trending),
        used, SECTION_SIZE, fill_when_empty=False,
    )

    new_arrivals = _dedup_take(get_new_arrivals(limit=over), used, SECTION_SIZE)

    return {
        "trending": trending,
        "featured": featured,
        # Whether any listing is featured at all — drives the Featured empty state
        # when a promoted listing exists but is currently unavailable.
        "featured_exists": has_featured_in_system(),
        "recommendations": recommendations,
        "popular_this_week": popular,
        "new_arrivals": new_arrivals,
        "recently_viewed": get_recently_viewed(current_user, limit=SECTION_SIZE),
        "categories": get_browse_categories(),
    }


def collect_listing_seller_ids(sections: dict) -> list[int]:
    """Union of seller ids across all listing sections — lets the homepage
    resolve premium badges in a single batched query (no per-card lookup)."""
    seller_ids: set[int] = set()
    for key, value in sections.items():
        # Skip the category tiles and any scalar flags (e.g. ``featured_exists``).
        if key == "categories" or not isinstance(value, list):
            continue
        for listing in value or ():
            if getattr(listing, "seller_id", None) is not None:
                seller_ids.add(listing.seller_id)
    return list(seller_ids)
