"""Numbers for the admin analytics dashboard (/admin/analytics/).

Everything is worked out from VisitorLog (one row per page view by a real person) and
ClickEvent. A *visit* (session) is a run of page views by the same browser with no gap
longer than 30 minutes, so entry pages, exit pages, bounces and page-to-page flow can be
worked out for old rows too.
"""
from collections import Counter, defaultdict
from datetime import datetime, time, timedelta
from statistics import median
from urllib.parse import urlparse

from django.db.models import Count
from django.utils import timezone

from .models import ClickEvent, VisitorLog

SESSION_GAP = timedelta(minutes=30)
ACTIVE_WINDOW = timedelta(minutes=5)
MAX_ROWS = 300_000

SEARCH = ('google.', 'bing.', 'yahoo.', 'duckduckgo.', 'yandex.', 'baidu.', 'ecosia.', 'search.brave', 'startpage.')
SOCIAL = ('facebook.', 'fb.', 'messenger.', 'instagram.', 'twitter.', 't.co', 'x.com', 'linkedin.', 'lnkd.in',
          'youtube.', 'youtu.be', 'reddit.', 'pinterest.', 'tiktok.', 'threads.', 'whatsapp.', 'telegram.', 't.me', 'quora.')
AI = ('chatgpt.', 'openai.', 'perplexity.', 'claude.ai', 'gemini.google', 'copilot.', 'you.com', 'phind.')
SECTIONS = (('/articles/', 'Articles'), ('/magazine/', 'Magazine'), ('/shop/', 'Shop'), ('/games/', 'Games'),
            ('/account/', 'Account'), ('/submit/', 'Submit'))
TIME_BUCKETS = ((10, 'Under 10s'), (30, '10–30s'), (60, '30s–1m'), (180, '1–3m'), (600, '3–10m'), (None, '10m+'))
SCROLL_MARKS = (25, 50, 75, 90)
RANGES = (('today', 'Today'), ('7', '7 days'), ('30', '30 days'), ('90', '90 days'), ('365', '12 months'))


def host(url):
    h = urlparse(url).netloc.lower() if url else ''
    return h[4:] if h.startswith('www.') else h


def source_of(referrer, utm_source=''):
    """Where a visit came from: Campaign, Search, AI assistant, Social, Other website or Direct."""
    if utm_source:
        return 'Campaign'
    h = host(referrer)
    if not h:
        return 'Direct'
    if any(s in h for s in AI):
        return 'AI assistant'
    if any(s in h for s in SEARCH):
        return 'Search'
    if any(s in h for s in SOCIAL):
        return 'Social'
    return 'Other website'


def section_of(path):
    if path == '/':
        return 'Home'
    for prefix, name in SECTIONS:
        if path.startswith(prefix):
            return name
    return 'Other pages'


def resolve_range(params, now=None):
    """Turn ?range=7 / ?range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD into (start, end, key)."""
    now = now or timezone.now()
    tz = timezone.get_current_timezone()
    today = timezone.localdate(now)
    key = params.get('range') or '7'
    if key == 'custom':
        try:
            d1 = datetime.strptime(params.get('from', ''), '%Y-%m-%d').date()
            d2 = datetime.strptime(params.get('to', ''), '%Y-%m-%d').date()
            if d2 < d1:
                d1, d2 = d2, d1
            start = timezone.make_aware(datetime.combine(d1, time.min), tz)
            end = min(timezone.make_aware(datetime.combine(d2 + timedelta(days=1), time.min), tz), now)
            return start, end, key
        except ValueError:
            key = '7'
    if key == 'today':
        return timezone.make_aware(datetime.combine(today, time.min), tz), now, key
    try:
        days = max(1, min(int(key), 730))
    except ValueError:
        days, key = 7, '7'
    start = timezone.make_aware(datetime.combine(today - timedelta(days=days - 1), time.min), tz)
    return start, now, key


def _rows(start, end, device=''):
    qs = VisitorLog.objects.filter(is_human=True, visit_time__gte=start, visit_time__lt=end)
    if device:
        qs = qs.filter(device=device)
    return list(qs.order_by('visitor_id', 'visit_time').values_list(
        'id', 'visitor_id', 'user_id', 'page_visited', 'page_title', 'referrer', 'device', 'visit_time',
        'last_seen', 'time_spent_seconds', 'scroll_pct', 'utm_source', 'utm_campaign')[:MAX_ROWS])


class View:
    __slots__ = ('id', 'visitor', 'user', 'path', 'title', 'ref', 'device', 'at', 'seen', 'secs', 'scroll',
                 'utm', 'campaign')

    def __init__(self, row):
        (self.id, self.visitor, self.user, self.path, self.title, self.ref, self.device, self.at, self.seen,
         self.secs, self.scroll, self.utm, self.campaign) = row

    @property
    def end(self):
        return max(self.seen or self.at, self.at + timedelta(seconds=self.secs))


def sessions_from(views):
    """Group views (sorted by visitor, then time) into visits."""
    out, current, prev = [], [], None
    for v in views:
        if prev is not None and (v.visitor != prev.visitor or v.at - prev.end > SESSION_GAP):
            out.append(current)
            current = []
        current.append(v)
        prev = v
    if current:
        out.append(current)
    return out


def _pct(part, whole):
    return round(part * 100 / whole, 1) if whole else 0


def _change(now_value, before):
    if not before:
        return None
    return round((now_value - before) * 100 / before)


def summary(views, sessions):
    timed = [v.secs for v in views if v.secs > 0]
    durations = [sum(v.secs for v in s) for s in sessions]
    visitors = {v.visitor for v in views}
    return {
        'visitors': len(visitors),
        'sessions': len(sessions),
        'views': len(views),
        'signed_in': len({v.visitor for v in views if v.user}),
        'avg_time': round(sum(timed) / len(timed)) if timed else 0,
        'median_time': round(median(timed)) if timed else 0,
        'avg_session': round(sum(durations) / len(durations)) if durations else 0,
        'total_minutes': round(sum(v.secs for v in views) / 60),
        'pages_per_session': round(len(views) / len(sessions), 2) if sessions else 0,
        'bounce_rate': _pct(sum(1 for s in sessions if len(s) == 1), len(sessions)),
        'avg_scroll': round(sum(v.scroll for v in views) / len(views)) if views else 0,
    }


def build(start, end, device='', page=''):
    """Everything the dashboard shows for one date range."""
    views = [View(r) for r in _rows(start, end, device)]
    sessions = sessions_from(views)
    kpi = summary(views, sessions)

    # Same length of time just before, for the "vs previous period" arrows.
    span = end - start
    prev_views = [View(r) for r in _rows(start - span, start, device)]
    prev = summary(prev_views, sessions_from(prev_views))
    for key in ('visitors', 'sessions', 'views', 'avg_time', 'avg_session', 'bounce_rate', 'avg_scroll', 'pages_per_session'):
        kpi[key + '_change'] = _change(kpi[key], prev[key])

    # New vs returning: returning = this browser was seen before the range started.
    in_range = VisitorLog.objects.filter(is_human=True, visit_time__gte=start, visit_time__lt=end)
    if device:
        in_range = in_range.filter(device=device)
    seen_before = set(VisitorLog.objects.filter(is_human=True, visit_time__lt=start,
                                                visitor_id__in=in_range.values('visitor_id'))
                      .values_list('visitor_id', flat=True).distinct()) if views else set()
    kpi['returning'] = len(seen_before)
    kpi['new'] = kpi['visitors'] - kpi['returning']
    kpi['returning_pct'] = _pct(kpi['returning'], kpi['visitors'])

    tz = timezone.get_current_timezone()
    titles = {}
    for v in views:
        if v.title:
            titles[v.path] = v.title

    # ── Per page ──
    pages = defaultdict(lambda: {'views': 0, 'visitors': set(), 'secs': 0, 'timed': 0, 'scroll': 0,
                                 'entries': 0, 'exits': 0, 'bounces': 0})
    flows, sources, ref_hosts, campaigns = Counter(), Counter(), Counter(), Counter()
    devices = defaultdict(lambda: {'sessions': 0, 'secs': 0, 'views': 0})
    entry_source = defaultdict(Counter)
    for s in sessions:
        first, last = s[0], s[-1]
        src = source_of(first.ref, first.utm)
        sources[src] += 1
        if first.ref and src != 'Direct':
            ref_hosts[host(first.ref)] += 1
        if first.campaign or first.utm:
            campaigns[(first.utm or '—', first.campaign or '—')] += 1
        pages[first.path]['entries'] += 1
        entry_source[first.path][src] += 1
        pages[last.path]['exits'] += 1
        if len(s) == 1:
            pages[first.path]['bounces'] += 1
        d = devices[first.device or 'unknown']
        d['sessions'] += 1
        d['secs'] += sum(v.secs for v in s)
        d['views'] += len(s)
        for a, b in zip(s, s[1:]):
            if a.path != b.path:
                flows[(a.path, b.path)] += 1
    for v in views:
        p = pages[v.path]
        p['views'] += 1
        p['visitors'].add(v.visitor)
        p['secs'] += v.secs
        p['timed'] += 1 if v.secs else 0
        p['scroll'] += v.scroll

    page_rows = []
    for path, p in pages.items():
        page_rows.append({
            'path': path, 'title': titles.get(path, ''), 'section': section_of(path), 'views': p['views'],
            'visitors': len(p['visitors']), 'avg_time': round(p['secs'] / p['timed']) if p['timed'] else 0,
            'total_minutes': round(p['secs'] / 60, 1), 'avg_scroll': round(p['scroll'] / p['views']) if p['views'] else 0,
            'entries': p['entries'], 'exits': p['exits'], 'exit_rate': _pct(p['exits'], p['views']),
            'bounce_rate': _pct(p['bounces'], p['entries']),
        })
    page_rows.sort(key=lambda r: (-r['views'], r['path']))
    by_time = sorted(page_rows, key=lambda r: (-r['total_minutes'], -r['views']))
    entries = sorted([r for r in page_rows if r['entries']], key=lambda r: -r['entries'])
    exits = sorted([r for r in page_rows if r['exits']], key=lambda r: -r['exits'])
    # Pages people tend to leave from: enough views to matter, highest exit rate.
    leaky = sorted([r for r in page_rows if r['views'] >= 5], key=lambda r: (-r['exit_rate'], -r['views']))

    sections = defaultdict(lambda: {'views': 0, 'secs': 0, 'timed': 0, 'visitors': set()})
    for v in views:
        sec = sections[section_of(v.path)]
        sec['views'] += 1
        sec['secs'] += v.secs
        sec['timed'] += 1 if v.secs else 0
        sec['visitors'].add(v.visitor)
    section_rows = sorted(({'name': k, 'views': x['views'], 'visitors': len(x['visitors']),
                            'minutes': round(x['secs'] / 60), 'avg_time': round(x['secs'] / x['timed']) if x['timed'] else 0}
                           for k, x in sections.items()), key=lambda r: -r['views'])

    # ── Over time ──
    days = []
    d, last_day = timezone.localdate(start), timezone.localdate(end - timedelta(microseconds=1))
    daily = defaultdict(lambda: {'views': 0, 'visitors': set(), 'secs': 0, 'timed': 0})
    daily_sessions = Counter()
    for v in views:
        x = daily[timezone.localtime(v.at, tz).date()]
        x['views'] += 1
        x['visitors'].add(v.visitor)
        x['secs'] += v.secs
        x['timed'] += 1 if v.secs else 0
    for s in sessions:
        daily_sessions[timezone.localtime(s[0].at, tz).date()] += 1
    while d <= last_day and len(days) < 800:
        x = daily.get(d)
        days.append({'date': d.isoformat(), 'label': d.strftime('%d %b'),
                     'visitors': len(x['visitors']) if x else 0, 'views': x['views'] if x else 0,
                     'sessions': daily_sessions.get(d, 0),
                     'avg_time': round(x['secs'] / x['timed']) if x and x['timed'] else 0})
        d += timedelta(days=1)

    hours = [[0] * 24 for _ in range(7)]
    for v in views:
        lt = timezone.localtime(v.at, tz)
        hours[lt.weekday()][lt.hour] += 1
    peak = max((max(r) for r in hours), default=0)
    weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
    heat = [{'day': weekdays[i], 'cells': [{'hour': h, 'n': n, 'level': (0 if not n else 1 + int(4 * n / (peak + 1)))}
                                         for h, n in enumerate(row)]} for i, row in enumerate(hours)]

    # ── Time and scroll ──
    buckets = Counter()
    for v in views:
        for limit, name in TIME_BUCKETS:
            if limit is None or v.secs < limit:
                buckets[name] += 1
                break
    time_dist = [{'label': name, 'n': buckets[name], 'pct': _pct(buckets[name], len(views))} for _, name in TIME_BUCKETS]
    scroll_dist = [{'mark': m, 'n': sum(1 for v in views if v.scroll >= m),
                    'pct': _pct(sum(1 for v in views if v.scroll >= m), len(views))} for m in SCROLL_MARKS]

    total_sessions = len(sessions) or 1
    source_rows = [{'name': k, 'n': n, 'pct': _pct(n, total_sessions)} for k, n in sources.most_common()]
    device_rows = [{'name': k, 'sessions': x['sessions'], 'pct': _pct(x['sessions'], total_sessions),
                    'avg_session': round(x['secs'] / x['sessions']) if x['sessions'] else 0,
                    'pages': round(x['views'] / x['sessions'], 1) if x['sessions'] else 0}
                   for k, x in sorted(devices.items(), key=lambda i: -i[1]['sessions'])]

    # ── Clicks ──
    clicks = ClickEvent.objects.filter(view__is_human=True, created_at__gte=start, created_at__lt=end)
    if device:
        clicks = clicks.filter(view__device=device)
    click_total = clicks.count()
    top_clicks = list(clicks.values('label', 'href', 'kind').annotate(n=Count('id'), pages=Count('page', distinct=True))
                      .order_by('-n')[:25])
    outbound = list(clicks.filter(kind='outbound').values('href').annotate(n=Count('id')).order_by('-n')[:15])
    click_kinds = [{'name': dict(ClickEvent.KINDS)[r['kind']], 'n': r['n'], 'pct': _pct(r['n'], click_total)}
                   for r in clicks.values('kind').annotate(n=Count('id')).order_by('-n')]
    click_pages = list(clicks.values('page').annotate(n=Count('id')).order_by('-n')[:40])
    click_sections = list(clicks.exclude(section='').values('section').annotate(n=Count('id')).order_by('-n')[:12])

    # ── Live ──
    now = timezone.now()
    live = VisitorLog.objects.filter(is_human=True, last_seen__gte=now - ACTIVE_WINDOW)
    live_pages = list(live.values('page_visited').annotate(n=Count('visitor_id', distinct=True)).order_by('-n')[:8])

    for r in click_pages:
        r['title'] = titles.get(r['page'], '')
    data = {
        'kpi': kpi, 'prev': prev, 'days': days, 'heat': heat,
        'pages': page_rows[:50], 'all_pages': page_rows, 'page_count': len(page_rows), 'by_time': by_time[:15], 'entries': entries[:12], 'exits': exits[:12], 'leaky': leaky[:10],
        'sections': section_rows, 'flows': [{'from': a, 'to': b, 'n': n, 'from_title': titles.get(a, ''),
                                             'to_title': titles.get(b, '')} for (a, b), n in flows.most_common(15)],
        'sources': source_rows, 'referrers': [{'host': h, 'n': n} for h, n in ref_hosts.most_common(12)],
        'campaigns': [{'source': s, 'campaign': c, 'n': n} for (s, c), n in campaigns.most_common(10)],
        'devices': device_rows, 'time_dist': time_dist, 'scroll_dist': scroll_dist,
        'click_total': click_total, 'top_clicks': top_clicks, 'outbound': outbound, 'click_kinds': click_kinds,
        'click_pages': click_pages, 'click_sections': click_sections,
        'clicks_per_view': round(click_total / len(views), 2) if views else 0,
        'live_count': live.values('visitor_id').distinct().count(), 'live_pages': live_pages,
        'row_limit_hit': len(views) >= MAX_ROWS, 'titles': titles,
    }
    if page:
        data['page_detail'] = page_detail(page, views, sessions, clicks, titles)
    return data


def page_detail(path, views, sessions, clicks, titles):
    """One page: where its readers came from, where they went next, what they clicked."""
    came, went = Counter(), Counter()
    pv = [v for v in views if v.path == path]
    for s in sessions:
        for i, v in enumerate(s):
            if v.path != path:
                continue
            if i:
                came['Same page (reloaded)' if s[i - 1].path == path else s[i - 1].path] += 1
            else:
                came[source_of(v.ref, v.utm) + ' (first page of visit)'] += 1
            if i + 1 < len(s):
                went['Same page (reloaded)' if s[i + 1].path == path else s[i + 1].path] += 1
            else:
                went['Left the website'] += 1
    page_clicks = clicks.filter(page=path)
    timed = [v.secs for v in pv if v.secs]
    devices = Counter(v.device for v in pv)
    by_device = {}
    for dev in ('desktop', 'mobile', 'tablet'):
        pts = list(page_clicks.filter(view__device=dev).values_list('x', 'y', 'label', 'kind')[:3000])
        if pts:
            by_device[dev] = [{'x': x / 10, 'y': y / 10, 'l': l, 'k': k} for x, y, l, k in pts]
    total = len(pv)
    return {
        'path': path, 'title': titles.get(path, ''), 'views': total, 'visitors': len({v.visitor for v in pv}),
        'avg_time': round(sum(timed) / len(timed)) if timed else 0,
        'median_time': round(median(timed)) if timed else 0,
        'avg_scroll': round(sum(v.scroll for v in pv) / total) if total else 0,
        'scroll_dist': [{'mark': m, 'pct': _pct(sum(1 for v in pv if v.scroll >= m), total)} for m in SCROLL_MARKS],
        'came': [{'name': k, 'n': n, 'pct': _pct(n, total)} for k, n in came.most_common(10)],
        'went': [{'name': k, 'n': n, 'pct': _pct(n, total)} for k, n in went.most_common(10)],
        'clicks': list(page_clicks.values('label', 'href', 'kind', 'section').annotate(n=Count('id')).order_by('-n')[:20]),
        'click_count': page_clicks.count(), 'devices': dict(devices), 'points': by_device,
    }

