from pyramid.view import view_config
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, case, and_, or_, cast, Float, Integer

from . import shop_editor_required
from ..models.page_session import PageSession
from ..models.product import Product
from ..models.price import Price
from ..models.comment import get_sentiment_summary_for_shop, get_sentiment_summary_for_product
from ..models.meta import now_timestamp, get_object_by_id
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
from ..lib.currency import cents_to_dollars


REFERRER_LABELS = {0: "Direct", 1: "Search", 2: "Social", 3: "Internal", 4: "Unknown"}
DEVICE_LABELS = {0: "Mobile", 1: "Tablet", 2: "Desktop"}
MIN_SESSIONS = 7
DAY_MS = 24 * 60 * 60 * 1000


def _cutoffs():
    """Return 1d, 7d, 14d, 21d, 28d, 365d cutoff timestamps in milliseconds."""
    now = now_timestamp()
    day_ms = 24 * 60 * 60 * 1000
    return {
        "1d": now - 1 * day_ms,
        "7d": now - 7 * day_ms,
        "14d": now - 14 * day_ms,
        "21d": now - 21 * day_ms,
        "28d": now - 28 * day_ms,
        "365d": now - 365 * day_ms,
    }


def _product_titles(dbsession, product_ids):
    """Batch-fetch product titles, URLs, and IDs for a set of product IDs."""
    if not product_ids:
        return {}
    products = (
        dbsession.query(Product)
        .filter(Product.id.in_(product_ids))
        .all()
    )
    return {
        p.id: {
            "title": p.title,
            "url": f"/{'p' if p.is_sellable else 'c'}/{p.id}/{p.slug}",
            "uuid_str": p.uuid_str,
        }
        for p in products
    }


def _fmt_ms(ms):
    """Format milliseconds as human-readable duration."""
    if ms is None:
        return "\u2014"
    s = int(ms / 1000)
    if s < 60:
        return f"{s}s"
    m, s = divmod(s, 60)
    if m < 60:
        return f"{m}m {s}s"
    h, m = divmod(m, 60)
    return f"{h}h {m}m"


def _fmt_pct(val):
    """Format a 0-1 float as a percentage string."""
    if val is None:
        return "\u2014"
    return f"{val * 100:.0f}%"


def _fmt_score(val):
    """Format a 0-5 score with one decimal place."""
    if val is None:
        return "\u2014"
    return f"{val:.1f}"


def _daily_buckets(dbsession, base_filter, days=28):
    """Bucket page sessions into daily counts for a bar chart.

    Returns oldest-first list of {"day_offset": int, "label": "Feb 18", "count": int}
    with zero-days filled so the list always has exactly `days` entries.
    """
    now = now_timestamp()
    cutoff = now - days * DAY_MS

    day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)

    rows = (
        dbsession.query(day_offset_expr.label("day_offset"), func.count().label("cnt"))
        .filter(base_filter, PageSession.created_timestamp > cutoff)
        .group_by(day_offset_expr)
        .all()
    )
    counts = {r.day_offset: r.cnt for r in rows}

    today = datetime.now(timezone.utc).date()
    buckets = []
    for i in range(days - 1, -1, -1):  # oldest first
        d = today - timedelta(days=i)
        buckets.append({
            "day_offset": i,
            "label": d.strftime("%b %-d"),
            "count": counts.get(i, 0),
        })
    return buckets


def _daily_avg_duration(dbsession, base_filter, days=28):
    """Bucket page sessions into daily avg wall_clock_ms for a line chart.

    Returns oldest-first list of {"day_offset", "label", "value", "tooltip"}.
    """
    now = now_timestamp()
    cutoff = now - days * DAY_MS
    day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)

    rows = (
        dbsession.query(
            day_offset_expr.label("day_offset"),
            func.avg(PageSession.wall_clock_ms).label("avg_ms"),
        )
        .filter(base_filter, PageSession.created_timestamp > cutoff)
        .group_by(day_offset_expr)
        .all()
    )
    avgs = {r.day_offset: r.avg_ms or 0 for r in rows}

    today = datetime.now(timezone.utc).date()
    buckets = []
    for i in range(days - 1, -1, -1):
        d = today - timedelta(days=i)
        val = avgs.get(i, 0)
        buckets.append({
            "day_offset": i,
            "label": d.strftime("%b %-d"),
            "value": val,
            "tooltip": _fmt_ms(val),
        })
    return buckets


def _daily_engagement(dbsession, base_filter, days=28):
    """Bucket sessions into daily engagement ratio (active_ms / wall_clock_ms).

    Returns oldest-first list with value as 0-1 float.
    """
    now = now_timestamp()
    cutoff = now - days * DAY_MS
    day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)

    rows = (
        dbsession.query(
            day_offset_expr.label("day_offset"),
            func.avg(
                cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
            ).label("ratio"),
        )
        .filter(base_filter, PageSession.created_timestamp > cutoff)
        .group_by(day_offset_expr)
        .all()
    )
    ratios = {r.day_offset: r.ratio or 0 for r in rows}

    today = datetime.now(timezone.utc).date()
    buckets = []
    for i in range(days - 1, -1, -1):
        d = today - timedelta(days=i)
        val = ratios.get(i, 0)
        buckets.append({
            "day_offset": i,
            "label": d.strftime("%b %-d"),
            "value": val,
            "tooltip": _fmt_pct(val),
        })
    return buckets


def _daily_bounce_rate(dbsession, all_sessions_filter, days=28):
    """Bucket sessions into daily bounce rate (visible < 7s / total).

    all_sessions_filter must NOT include the 7s visible floor.
    Returns oldest-first list with value as 0-1 float.
    """
    now = now_timestamp()
    cutoff = now - days * DAY_MS
    day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)

    rows = (
        dbsession.query(
            day_offset_expr.label("day_offset"),
            func.count().label("total"),
            func.sum(case(
                (PageSession.visible_ms < 7000, 1), else_=0
            )).label("bounced"),
        )
        .filter(all_sessions_filter, PageSession.created_timestamp > cutoff)
        .group_by(day_offset_expr)
        .all()
    )
    rates = {}
    for r in rows:
        if r.total and r.total > 0:
            rates[r.day_offset] = (r.bounced or 0) / r.total
        else:
            rates[r.day_offset] = 0

    today = datetime.now(timezone.utc).date()
    buckets = []
    for i in range(days - 1, -1, -1):
        d = today - timedelta(days=i)
        val = rates.get(i, 0)
        buckets.append({
            "day_offset": i,
            "label": d.strftime("%b %-d"),
            "value": val,
            "tooltip": _fmt_pct(val),
        })
    return buckets


def _daily_referrer_counts(dbsession, base_filter, days=28):
    """Bucket external referrer sessions into daily counts for a line chart.

    Excludes direct (class=0) and internal (class=3) traffic.
    Returns oldest-first list of {"day_offset", "label", "value", "tooltip"}.
    """
    now = now_timestamp()
    cutoff = now - days * DAY_MS
    day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)

    rows = (
        dbsession.query(
            day_offset_expr.label("day_offset"),
            func.count().label("cnt"),
        )
        .filter(
            base_filter,
            PageSession.created_timestamp > cutoff,
            PageSession.referrer_domain.isnot(None),
            PageSession.referrer_class != 0,   # exclude direct
            PageSession.referrer_class != 3,   # exclude internal
        )
        .group_by(day_offset_expr)
        .all()
    )
    counts = {r.day_offset: r.cnt for r in rows}

    today = datetime.now(timezone.utc).date()
    buckets = []
    for i in range(days - 1, -1, -1):
        d = today - timedelta(days=i)
        val = counts.get(i, 0)
        buckets.append({
            "day_offset": i,
            "label": d.strftime("%b %-d"),
            "value": val,
            "tooltip": str(val),
        })
    return buckets


def _top_search_keywords(dbsession, shop_id, days=21, limit=21):
    """Top internal search keywords for a shop, last N days."""
    from ..models.shop_search_request import ShopSearchRequest
    cutoff = now_timestamp() - days * DAY_MS
    rows = (
        dbsession.query(
            ShopSearchRequest.keywords,
            func.count().label("search_count"),
            func.sum(ShopSearchRequest.hit_count).label("total_hits"),
        )
        .filter(
            ShopSearchRequest.shop_id == shop_id,
            ShopSearchRequest.created_timestamp > cutoff,
        )
        .group_by(ShopSearchRequest.keywords)
        .order_by(func.count().desc())
        .limit(limit)
        .all()
    )
    return [
        {
            "keywords": r.keywords,
            "search_count": r.search_count,
            "total_hits": r.total_hits or 0,
            "avg_hits": round((r.total_hits or 0) / max(r.search_count, 1), 1),
        }
        for r in rows
    ]


def _top_referrer_domains(dbsession, base_filter, days=21, limit=21):
    """Top referrer domains, excluding internal and direct."""
    cutoff = now_timestamp() - days * DAY_MS
    rows = (
        dbsession.query(
            PageSession.referrer_domain,
            func.count().label("cnt"),
        )
        .filter(
            base_filter,
            PageSession.created_timestamp > cutoff,
            PageSession.referrer_domain.isnot(None),
            PageSession.referrer_class != 3,  # exclude internal
            PageSession.referrer_class != 0,  # exclude direct
        )
        .group_by(PageSession.referrer_domain)
        .order_by(func.count().desc())
        .limit(limit)
        .all()
    )
    total = sum(r.cnt for r in rows) or 1
    return [
        {
            "domain": r.referrer_domain,
            "count": r.cnt,
            "pct": _fmt_pct(r.cnt / total),
            "pct_raw": r.cnt / total * 100,
        }
        for r in rows
    ]


def _top_referrer_queries(dbsession, base_filter, days=21, limit=21):
    """Top search queries from external search engines."""
    cutoff = now_timestamp() - days * DAY_MS
    rows = (
        dbsession.query(
            PageSession.referrer_query,
            func.count().label("cnt"),
        )
        .filter(
            base_filter,
            PageSession.created_timestamp > cutoff,
            PageSession.referrer_query.isnot(None),
            PageSession.referrer_query != "",
        )
        .group_by(PageSession.referrer_query)
        .order_by(func.count().desc())
        .limit(limit)
        .all()
    )
    return [
        {
            "query": r.referrer_query,
            "count": r.cnt,
        }
        for r in rows
    ]


@view_config(route_name="shop_analytics", renderer="analytics.j2")
@shop_editor_required()
def shop_analytics(request):
    shop = request.shop
    db = request.dbsession
    cuts = _cutoffs()
    cutoff_7d, cutoff_14d, cutoff_21d = cuts["7d"], cuts["14d"], cuts["21d"]

    # Exclude owner/editor traffic from all funnel metrics
    _not_owner = or_(PageSession.is_owner == None, PageSession.is_owner == False)  # noqa: E711,E712

    def _view_filter(cutoff):
        """Sessions that count as views (7s visible) after cutoff."""
        return and_(
            PageSession.shop_id == shop.id,
            PageSession.visible_ms >= 7000,
            PageSession.created_timestamp > cutoff,
            _not_owner,
        )

    def _session_filter(cutoff):
        """Sessions with real engagement (7s wall clock) after cutoff."""
        return and_(
            PageSession.shop_id == shop.id,
            PageSession.wall_clock_ms >= 7000,
            PageSession.created_timestamp > cutoff,
            _not_owner,
        )

    # Non-temporal view base for daily bucketing
    _view_base = and_(
        PageSession.shop_id == shop.id,
        PageSession.visible_ms >= 7000,
        _not_owner,
    )

    # --- Daily views (28-day bar chart) ---
    daily_views = _daily_buckets(db, _view_base, 28)

    # --- Line chart trends (28 days) ---
    daily_session_duration = _daily_avg_duration(db, _view_base, 28)
    daily_engagement = _daily_engagement(db, _view_base, 28)
    _all_sessions_base = and_(
        PageSession.shop_id == shop.id,
        _not_owner,
    )
    daily_bounce = _daily_bounce_rate(db, _all_sessions_base, 28)

    # --- Section 1: Overview Strip (last 7 days) ---

    ov = db.query(
        func.count().label("total_views"),
        func.count(func.distinct(PageSession.product_id)).label("unique_products"),
        func.avg(PageSession.wall_clock_ms).label("avg_session_ms"),
    ).filter(_view_filter(cutoff_7d)).one()

    ring_size = len(shop.discovery_ring) or 1
    _ring_base = and_(
        PageSession.shop_id == shop.id,
        PageSession.ring_position.isnot(None),
        _not_owner,
    )

    def _ring_seen(cutoff=None):
        filt = [_ring_base]
        if cutoff is not None:
            filt.append(PageSession.created_timestamp > cutoff)
        return db.query(
            func.count(func.distinct(PageSession.product_id)),
        ).filter(*filt).scalar() or 0

    ring_consumed = {
        "1d": _ring_seen(cuts["1d"]),
        "7d": _ring_seen(cuts["7d"]),
        "28d": _ring_seen(cuts["28d"]),
        "365d": _ring_seen(cuts["365d"]),
        "all": _ring_seen(),
    }

    top_src = db.query(
        PageSession.referrer_class, func.count().label("cnt"),
    ).filter(
        _view_filter(cutoff_7d),
    ).group_by(
        PageSession.referrer_class,
    ).order_by(func.count().desc()).first()

    dev_rows_7d = db.query(
        PageSession.device_class, func.count().label("cnt"),
    ).filter(_view_filter(cutoff_7d)).group_by(PageSession.device_class).all()
    dev_total_7d = sum(r.cnt for r in dev_rows_7d) or 1
    dev_split_str = " \u00b7 ".join(
        f"{_fmt_pct(r.cnt / dev_total_7d)} {DEVICE_LABELS.get(r.device_class, '?')}"
        for r in sorted(dev_rows_7d, key=lambda r: -(r.cnt or 0))
    ) or "\u2014"

    overview = {
        "total_views": ov.total_views or 0,
        "unique_products": ov.unique_products or 0,
        "avg_session": _fmt_ms(ov.avg_session_ms),
        "ring_consumed": {
            k: _fmt_pct(v / ring_size) for k, v in ring_consumed.items()
        },
        "top_source": REFERRER_LABELS.get(top_src[0], "\u2014") if top_src else "\u2014",
        "device_split": dev_split_str,
    }

    # --- Section 2: Top Products by Views (7d / 14d / 21d) ---

    views_7d_expr = func.sum(case(
        (PageSession.created_timestamp > cutoff_7d, 1), else_=0,
    ))
    views_14d_expr = func.sum(case(
        (PageSession.created_timestamp > cutoff_14d, 1), else_=0,
    ))

    top_prods_raw = db.query(
        PageSession.product_id,
        views_7d_expr.label("views_7d"),
        views_14d_expr.label("views_14d"),
        func.count().label("views_21d"),
    ).filter(
        _view_filter(cutoff_21d),
    ).group_by(
        PageSession.product_id,
    ).order_by(views_7d_expr.desc()).limit(21).all()

    # --- Section 3: Ring Entry Points (top 7 front doors, 21d) ---

    ring_raw = db.query(
        PageSession.product_id, func.count().label("cnt"),
    ).filter(
        PageSession.shop_id == shop.id,
        PageSession.is_ring_entry == True,  # noqa: E712
        PageSession.created_timestamp > cutoff_21d,
        _not_owner,
    ).group_by(PageSession.product_id).order_by(func.count().desc()).limit(42).all()

    ring_total = db.query(func.count()).filter(
        PageSession.shop_id == shop.id,
        PageSession.is_ring_entry == True,  # noqa: E712
        PageSession.created_timestamp > cutoff_21d,
        _not_owner,
    ).scalar() or 1

    # --- Section 4: Engagement Leaders (active_ms / wall_clock_ms) ---

    eng_expr = func.avg(
        cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
    )
    eng_raw = db.query(
        PageSession.product_id, eng_expr.label("score"), func.count().label("sessions"),
    ).filter(
        _session_filter(cutoff_21d),
    ).group_by(
        PageSession.product_id,
    ).having(func.count() >= MIN_SESSIONS).order_by(eng_expr.desc()).limit(7).all()

    # --- Section 5: Attention Holders (visible_ms / wall_clock_ms) ---

    att_expr = func.avg(
        cast(PageSession.visible_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
    )
    att_raw = db.query(
        PageSession.product_id, att_expr.label("score"), func.count().label("sessions"),
    ).filter(
        _session_filter(cutoff_21d),
    ).group_by(
        PageSession.product_id,
    ).having(func.count() >= MIN_SESSIONS).order_by(att_expr.desc()).limit(7).all()

    # --- Section 6: Study Material (learning score: rewind, slow, re-read) ---

    learn_expr = (
        case((PageSession.media_seek_back_count > 0, 1), else_=0)
        + case(
            (and_(PageSession.media_speed.isnot(None), PageSession.media_speed < 1.0), 1),
            else_=0,
        )
        + case((PageSession.scroll_direction_changes > 3, 1), else_=0)
        + case((PageSession.media_pause_count > 2, 1), else_=0)
        + case(
            (
                and_(
                    PageSession.wall_clock_ms > 0,
                    cast(PageSession.active_ms, Float) / PageSession.wall_clock_ms > 0.7,
                ),
                1,
            ),
            else_=0,
        )
    )
    learn_avg = func.avg(learn_expr)
    learn_raw = db.query(
        PageSession.product_id, learn_avg.label("score"), func.count().label("sessions"),
    ).filter(
        _session_filter(cutoff_21d),
    ).group_by(
        PageSession.product_id,
    ).having(func.count() >= MIN_SESSIONS).order_by(learn_avg.desc()).limit(7).all()

    # --- Section 7: Background Favorites (passive score: lean-back plays) ---

    pass_expr = (
        case(
            (
                and_(
                    PageSession.wall_clock_ms > 0,
                    cast(PageSession.visible_ms, Float) / PageSession.wall_clock_ms > 0.7,
                ),
                1,
            ),
            else_=0,
        )
        + case(
            (
                and_(
                    PageSession.wall_clock_ms > 0,
                    cast(PageSession.active_ms, Float) / PageSession.wall_clock_ms < 0.3,
                ),
                1,
            ),
            else_=0,
        )
        + case((PageSession.media_percent_played > 0.69, 1), else_=0)
        + case((PageSession.media_play_count == 1, 1), else_=0)
        + case((PageSession.media_pause_count == 0, 1), else_=0)
    )
    pass_avg = func.avg(pass_expr)
    pass_raw = db.query(
        PageSession.product_id, pass_avg.label("score"), func.count().label("sessions"),
    ).filter(
        _session_filter(cutoff_21d),
    ).group_by(
        PageSession.product_id,
    ).having(func.count() >= MIN_SESSIONS).order_by(pass_avg.desc()).limit(7).all()

    # --- Section 8: Traffic Sources (21d) ---

    traffic_raw = db.query(
        PageSession.referrer_class, func.count().label("cnt"),
    ).filter(
        _view_filter(cutoff_21d),
    ).group_by(PageSession.referrer_class).order_by(func.count().desc()).all()
    traffic_total = sum(r.cnt for r in traffic_raw) or 1

    # --- Section 9: Device Split (21d) ---

    device_raw = db.query(
        PageSession.device_class, func.count().label("cnt"),
    ).filter(
        _view_filter(cutoff_21d),
    ).group_by(PageSession.device_class).order_by(func.count().desc()).all()
    device_total = sum(r.cnt for r in device_raw) or 1

    # --- Video Watch Metrics (21d, sessions with media only) ---

    _media_filter = and_(
        PageSession.shop_id == shop.id,
        PageSession.media_duration_ms.isnot(None),
        PageSession.media_duration_ms > 0,
        PageSession.created_timestamp > cutoff_21d,
        _not_owner,
    )

    video_agg = db.query(
        func.avg(PageSession.media_percent_played).label("avg_pct"),
        func.avg(
            case((PageSession.media_completed == True, 1.0), else_=0.0)  # noqa: E712
        ).label("completion_rate"),
        func.avg(PageSession.media_seek_count).label("avg_seeks"),
        func.avg(PageSession.media_seek_back_count).label("avg_rewinds"),
        func.avg(PageSession.media_pause_count).label("avg_pauses"),
        func.avg(PageSession.media_speed).label("avg_speed"),
        func.count().label("total_sessions"),
    ).filter(_media_filter).one()

    video_overview = None
    if video_agg.total_sessions and video_agg.total_sessions >= 1:
        video_overview = {
            "avg_pct_played": _fmt_pct(video_agg.avg_pct),
            "completion_rate": _fmt_pct(video_agg.completion_rate),
            "avg_seeks": f"{video_agg.avg_seeks:.1f}" if video_agg.avg_seeks else "\u2014",
            "avg_rewinds": f"{video_agg.avg_rewinds:.1f}" if video_agg.avg_rewinds else "\u2014",
            "avg_pauses": f"{video_agg.avg_pauses:.1f}" if video_agg.avg_pauses else "\u2014",
            "avg_speed": f"{video_agg.avg_speed:.2f}x" if video_agg.avg_speed else "\u2014",
            "total_sessions": video_agg.total_sessions,
        }

    # Per-product video breakdown (top 14 by avg % played, min 7 sessions)
    video_prod_raw = db.query(
        PageSession.product_id,
        func.avg(PageSession.media_percent_played).label("avg_pct"),
        func.avg(
            case((PageSession.media_completed == True, 1.0), else_=0.0)  # noqa: E712
        ).label("completion_rate"),
        func.avg(PageSession.media_seek_count).label("avg_seeks"),
        func.avg(PageSession.media_pause_count).label("avg_pauses"),
        func.count().label("sessions"),
    ).filter(_media_filter).group_by(
        PageSession.product_id,
    ).having(func.count() >= MIN_SESSIONS).order_by(
        func.avg(PageSession.media_percent_played).desc()
    ).limit(14).all()

    # --- Comment Sentiment (21d) ---

    sentiment = get_sentiment_summary_for_shop(db, shop.id, cutoff_21d)

    # --- Recent Price Changes (21d) ---

    price_changes_raw = (
        db.query(Price)
        .join(Product)
        .filter(
            Product.shop_id == shop.id,
            Price.created_timestamp > cutoff_21d,
        )
        .order_by(Price.created_timestamp.desc())
        .limit(21)
        .all()
    )

    # --- Resolve all product titles in one batch ---

    all_pids = set()
    for rows in (top_prods_raw, ring_raw, eng_raw, att_raw, learn_raw, pass_raw, video_prod_raw):
        for r in rows:
            all_pids.add(r.product_id)
    for pc in price_changes_raw:
        all_pids.add(pc.product_id)
    titles = _product_titles(db, list(all_pids))

    def _t(pid):
        return titles.get(pid, {}).get("title", "Unknown")

    def _u(pid):
        return titles.get(pid, {}).get("url", "#")

    def _a(pid):
        """Analytics URL for a product."""
        uid = titles.get(pid, {}).get("uuid_str", "")
        return f"/s/{shop.uuid_str}/analytics/{uid}" if uid else "#"

    # --- Build template-ready data ---

    top_products = []
    top_max_7d = max((r.views_7d or 0 for r in top_prods_raw), default=0) or 1
    for i, r in enumerate(top_prods_raw):
        v7, v14 = r.views_7d or 0, r.views_14d or 0
        top_products.append({
            "rank": i + 1,
            "title": _t(r.product_id),
            "url": _u(r.product_id),
            "analytics_url": _a(r.product_id),
            "views_7d": v7,
            "views_14d": v14,
            "views_21d": r.views_21d or 0,
            "trend": "rising" if v14 > 0 and v7 > v14 / 2 else "falling",
            "pct_of_max": v7 / top_max_7d * 100,
        })

    ring_entries = [
        {
            "rank": i + 1,
            "title": _t(r.product_id),
            "url": _u(r.product_id),
            "analytics_url": _a(r.product_id),
            "count": r.cnt,
            "pct": _fmt_pct(r.cnt / ring_total),
        }
        for i, r in enumerate(ring_raw)
    ]

    def _ranked(raw, score_fmt):
        return [
            {
                "rank": i + 1,
                "title": _t(r.product_id),
                "url": _u(r.product_id),
                "analytics_url": _a(r.product_id),
                "score": score_fmt(r.score),
                "sessions": r.sessions,
            }
            for i, r in enumerate(raw)
        ]

    video_products = [
        {
            "rank": i + 1,
            "title": _t(r.product_id),
            "url": _u(r.product_id),
            "analytics_url": _a(r.product_id),
            "avg_pct": _fmt_pct(r.avg_pct),
            "completion_rate": _fmt_pct(r.completion_rate),
            "avg_seeks": f"{r.avg_seeks:.1f}" if r.avg_seeks else "\u2014",
            "avg_pauses": f"{r.avg_pauses:.1f}" if r.avg_pauses else "\u2014",
            "sessions": r.sessions,
        }
        for i, r in enumerate(video_prod_raw)
    ]

    price_changes = [
        {
            "title": _t(pc.product_id),
            "url": _u(pc.product_id),
            "analytics_url": _a(pc.product_id),
            "price": f"${cents_to_dollars(pc.price_in_cents):.2f}",
            "ago": timestamp_to_ago_string(pc.created_timestamp),
            "datetime": str(timestamp_to_datetime(pc.created_timestamp)),
        }
        for pc in price_changes_raw
    ]

    daily_views_max = max((b["count"] for b in daily_views), default=0)

    # --- Internal Search Keywords (21d) ---
    top_keywords = _top_search_keywords(db, shop.id, 21, 21)

    # --- Referrer Domains & Search Queries (21d) ---
    top_referrer_domains = _top_referrer_domains(db, _view_base, 21, 21)
    top_referrer_queries = _top_referrer_queries(db, _view_base, 21, 21)

    # --- Daily External Referrer Trend (28 days) ---
    daily_referrers = _daily_referrer_counts(db, _view_base, 28)

    return {
        "overview": overview,
        "ring_size": ring_size,
        "daily_views": daily_views,
        "daily_views_max": daily_views_max,
        "daily_session_duration": daily_session_duration,
        "daily_session_duration_max": max((b["value"] for b in daily_session_duration), default=0),
        "daily_engagement": daily_engagement,
        "daily_bounce": daily_bounce,
        "top_products": top_products,
        "ring_entries": ring_entries,
        "engagement": _ranked(eng_raw, _fmt_pct),
        "attention": _ranked(att_raw, _fmt_pct),
        "learning": _ranked(learn_raw, _fmt_score),
        "passive": _ranked(pass_raw, _fmt_score),
        "video_overview": video_overview,
        "video_products": video_products,
        "sentiment": sentiment,
        "price_changes": price_changes,
        "top_keywords": top_keywords,
        "top_referrer_domains": top_referrer_domains,
        "top_referrer_queries": top_referrer_queries,
        "daily_referrers": daily_referrers,
        "daily_referrers_max": max((b["value"] for b in daily_referrers), default=0),
        "traffic": [
            {
                "source": REFERRER_LABELS.get(r.referrer_class, "Unknown"),
                "count": r.cnt,
                "pct": _fmt_pct(r.cnt / traffic_total),
                "pct_raw": r.cnt / traffic_total * 100,
            }
            for r in traffic_raw
        ],
        "devices": [
            {
                "device": DEVICE_LABELS.get(r.device_class, "Unknown"),
                "count": r.cnt,
                "pct": _fmt_pct(r.cnt / device_total),
                "pct_raw": r.cnt / device_total * 100,
            }
            for r in device_raw
        ],
    }


@view_config(route_name="product_analytics", renderer="analytics_product.j2")
@shop_editor_required()
def product_analytics(request):
    from pyramid.httpexceptions import HTTPNotFound

    shop = request.shop
    db = request.dbsession
    product = get_object_by_id(db, request.matchdict["product_id"], Product)
    if not product or product.shop_id != shop.id:
        raise HTTPNotFound()

    cuts = _cutoffs()
    cutoff_7d = cuts["7d"]
    cutoff_21d = cuts["21d"]

    _not_owner = or_(PageSession.is_owner == None, PageSession.is_owner == False)  # noqa: E711,E712

    def _pf(cutoff):
        """Product view filter: 7s visible on this product after cutoff."""
        return and_(
            PageSession.product_id == product.id,
            PageSession.visible_ms >= 7000,
            PageSession.created_timestamp > cutoff,
            _not_owner,
        )

    def _sf(cutoff):
        """Product session filter: 7s wall clock after cutoff."""
        return and_(
            PageSession.product_id == product.id,
            PageSession.wall_clock_ms >= 7000,
            PageSession.created_timestamp > cutoff,
            _not_owner,
        )

    # Non-temporal view base for daily bucketing
    _pf_base = and_(
        PageSession.product_id == product.id,
        PageSession.visible_ms >= 7000,
        _not_owner,
    )
    daily_views = _daily_buckets(db, _pf_base, 28)

    # --- Line chart trends (28 days) ---
    daily_session_duration = _daily_avg_duration(db, _pf_base, 28)
    daily_engagement_data = _daily_engagement(db, _pf_base, 28)
    _all_product_sessions = and_(
        PageSession.product_id == product.id,
        _not_owner,
    )
    daily_bounce = _daily_bounce_rate(db, _all_product_sessions, 28)

    # --- Overview ---
    ov = db.query(
        func.count().label("views"),
        func.avg(PageSession.wall_clock_ms).label("avg_session_ms"),
    ).filter(_pf(cutoff_7d)).one()

    bounce_q = db.query(
        func.count().label("total"),
        func.sum(case(
            (PageSession.wall_clock_ms < 7000, 1), else_=0,
        )).label("bounced"),
    ).filter(
        PageSession.product_id == product.id,
        PageSession.created_timestamp > cutoff_7d,
        _not_owner,
    ).one()
    bounce_rate = None
    if bounce_q.total and bounce_q.total > 0:
        bounce_rate = (bounce_q.bounced or 0) / bounce_q.total

    lifetime_views = db.query(func.count()).filter(
        PageSession.product_id == product.id,
        PageSession.visible_ms >= 7000,
        _not_owner,
    ).scalar() or 0

    overview = {
        "views_7d": ov.views or 0,
        "avg_session": _fmt_ms(ov.avg_session_ms),
        "bounce_rate": _fmt_pct(bounce_rate),
        "lifetime_views": lifetime_views,
    }

    # --- Views over time ---
    views_over_time = {}
    for label, key in [("7d", "7d"), ("14d", "14d"), ("21d", "21d"), ("28d", "28d"), ("365d", "365d")]:
        views_over_time[label] = db.query(func.count()).filter(
            _pf(cuts[key])
        ).scalar() or 0
    views_over_time["lifetime"] = lifetime_views

    # --- Video Watch Metrics ---
    video = None
    _mf = and_(
        PageSession.product_id == product.id,
        PageSession.media_duration_ms.isnot(None),
        PageSession.media_duration_ms > 0,
        PageSession.created_timestamp > cutoff_21d,
        _not_owner,
    )
    vagg = db.query(
        func.avg(PageSession.media_percent_played).label("avg_pct"),
        func.avg(
            case((PageSession.media_completed == True, 1.0), else_=0.0)  # noqa: E712
        ).label("completion_rate"),
        func.avg(PageSession.media_seek_count).label("avg_seeks"),
        func.avg(PageSession.media_seek_back_count).label("avg_rewinds"),
        func.avg(PageSession.media_pause_count).label("avg_pauses"),
        func.avg(PageSession.media_speed).label("avg_speed"),
        func.avg(PageSession.media_play_ms).label("avg_play_ms"),
        func.avg(PageSession.media_duration_ms).label("avg_duration_ms"),
        func.count().label("sessions"),
    ).filter(_mf).one()

    if vagg.sessions and vagg.sessions >= 1:
        play_ratio = None
        if vagg.avg_duration_ms and vagg.avg_duration_ms > 0:
            play_ratio = (vagg.avg_play_ms or 0) / vagg.avg_duration_ms
        video = {
            "avg_pct_played": _fmt_pct(vagg.avg_pct),
            "completion_rate": _fmt_pct(vagg.completion_rate),
            "avg_seeks": f"{vagg.avg_seeks:.1f}" if vagg.avg_seeks else "\u2014",
            "avg_rewinds": f"{vagg.avg_rewinds:.1f}" if vagg.avg_rewinds else "\u2014",
            "avg_pauses": f"{vagg.avg_pauses:.1f}" if vagg.avg_pauses else "\u2014",
            "avg_speed": f"{vagg.avg_speed:.2f}x" if vagg.avg_speed else "\u2014",
            "play_duration_ratio": _fmt_pct(play_ratio),
            "sessions": vagg.sessions,
        }

    # --- Traffic Sources (21d) ---
    traffic_raw = db.query(
        PageSession.referrer_class, func.count().label("cnt"),
    ).filter(_pf(cutoff_21d)).group_by(
        PageSession.referrer_class,
    ).order_by(func.count().desc()).all()
    traffic_total = sum(r.cnt for r in traffic_raw) or 1

    traffic = [
        {
            "source": REFERRER_LABELS.get(r.referrer_class, "Unknown"),
            "count": r.cnt,
            "pct": _fmt_pct(r.cnt / traffic_total),
            "pct_raw": r.cnt / traffic_total * 100,
        }
        for r in traffic_raw
    ]

    # --- Devices (21d) ---
    device_raw = db.query(
        PageSession.device_class, func.count().label("cnt"),
    ).filter(_pf(cutoff_21d)).group_by(
        PageSession.device_class,
    ).order_by(func.count().desc()).all()
    device_total = sum(r.cnt for r in device_raw) or 1

    devices = [
        {
            "device": DEVICE_LABELS.get(r.device_class, "Unknown"),
            "count": r.cnt,
            "pct": _fmt_pct(r.cnt / device_total),
            "pct_raw": r.cnt / device_total * 100,
        }
        for r in device_raw
    ]

    # --- Ring Entries (21d) ---
    ring_entry_count = db.query(func.count()).filter(
        PageSession.product_id == product.id,
        PageSession.is_ring_entry == True,  # noqa: E712
        PageSession.created_timestamp > cutoff_21d,
        _not_owner,
    ).scalar() or 0

    # --- Engagement (21d) ---
    eng_q = db.query(
        func.avg(
            cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
        ).label("active_ratio"),
        func.avg(
            cast(PageSession.visible_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
        ).label("visible_ratio"),
        func.avg(PageSession.scroll_depth_max).label("avg_scroll"),
        func.avg(PageSession.scroll_direction_changes).label("avg_dir_changes"),
        func.count().label("sessions"),
    ).filter(_sf(cutoff_21d)).one()

    engagement = None
    if eng_q.sessions and eng_q.sessions >= 1:
        engagement = {
            "active_ratio": _fmt_pct(eng_q.active_ratio),
            "visible_ratio": _fmt_pct(eng_q.visible_ratio),
            "avg_scroll_depth": _fmt_pct(eng_q.avg_scroll),
            "avg_direction_changes": f"{eng_q.avg_dir_changes:.1f}" if eng_q.avg_dir_changes else "\u2014",
            "sessions": eng_q.sessions,
        }

    # --- Comment Sentiment (21d) ---
    sentiment = get_sentiment_summary_for_product(db, product.id, cutoff_21d)

    # --- Price History (last 21 changes) ---
    price_changes_raw = (
        db.query(Price)
        .filter(Price.product_id == product.id)
        .order_by(Price.created_timestamp.desc())
        .limit(21)
        .all()
    )
    price_changes = [
        {
            "price": f"${cents_to_dollars(pc.price_in_cents):.2f}",
            "ago": timestamp_to_ago_string(pc.created_timestamp),
            "datetime": str(timestamp_to_datetime(pc.created_timestamp)),
        }
        for pc in price_changes_raw
    ]

    product_url = f"/{'p' if product.is_sellable else 'c'}/{product.uuid_str}/{product.slug}"

    daily_views_max = max((b["count"] for b in daily_views), default=0)

    # --- Referrer Domains & Search Queries (21d) ---
    top_referrer_domains = _top_referrer_domains(db, _pf_base, 21, 21)
    top_referrer_queries = _top_referrer_queries(db, _pf_base, 21, 21)

    # --- Daily External Referrer Trend (28 days) ---
    daily_referrers = _daily_referrer_counts(db, _pf_base, 28)

    return {
        "product": product,
        "product_url": product_url,
        "overview": overview,
        "views_over_time": views_over_time,
        "daily_views": daily_views,
        "daily_views_max": daily_views_max,
        "daily_session_duration": daily_session_duration,
        "daily_session_duration_max": max((b["value"] for b in daily_session_duration), default=0),
        "daily_engagement": daily_engagement_data,
        "daily_bounce": daily_bounce,
        "video": video,
        "traffic": traffic,
        "devices": devices,
        "ring_entry_count": ring_entry_count,
        "engagement": engagement,
        "sentiment": sentiment,
        "price_changes": price_changes,
        "top_referrer_domains": top_referrer_domains,
        "top_referrer_queries": top_referrer_queries,
        "daily_referrers": daily_referrers,
        "daily_referrers_max": max((b["value"] for b in daily_referrers), default=0),
    }
