import hmac
import os
import platform
import random
import re
import socket
import statistics
import time
from datetime import timedelta
from pathlib import Path

from django import get_version as django_version
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.contrib.sessions.models import Session
from django.core.cache import cache
from django.db.models import Count, Sum
from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
from django.utils import timezone as tz

from common.git_utils import get_git_commit_hash
from common.middleware import gate_whitelist
from library.models import (
    Comment,
    FileIndex,
    Project,
    ProjectAsset,
    ProjectDraft,
    Rating,
    Tag,
    TempUpload,
    Version,
    VersionFile,
)
from announcements.models import Announcement, AnnouncementRead, Notification


def _under_lockout(request):
    lock_until = request.session.get('lock_until')
    if lock_until and time.time() < lock_until:
        return True
    return False


def _start_lockout(request):
    duration = random.randint(30, 300)
    request.session['lock_until'] = time.time() + duration


@gate_whitelist
def gate(request):
    if request.method == 'POST':
        if _under_lockout(request):
            return render(request, 'landing/gate.html', {
                'error': 'Incorrect password.',
            })

        submitted = request.POST.get('password', '')
        master = settings.PASSWORD or ''
        if hmac.compare_digest(submitted, master):
            request.session['authorized'] = True
            request.session.pop('lock_until', None)
            return redirect('landing:home')
        else:
            _start_lockout(request)
            return render(request, 'landing/gate.html', {
                'error': 'Incorrect password.',
            })

    if request.session.get('authorized'):
        return redirect('landing:home')

    return render(request, 'landing/gate.html')


def home(request):
    total_downloads = Version.objects.aggregate(total=Sum('downloads'))['total'] or 0
    creators = (
        get_user_model().objects.filter(projects__isnull=False).distinct().count()
    )

    # Per-category Popular (by rating) + Recent columns, 6 packs each.
    def _base_query(category):
        return (
            Project.objects.filter(category=category)
            .select_related('owner', 'thumbnail')
            .prefetch_related('versions')
            .annotate(downloads_total=Sum('versions__downloads'))
        )

    home_sections = []
    for value, label in Project.CATEGORY_CHOICES:
        base = _base_query(value)
        popular = list(base.order_by('-rating_score', '-created_at')[:6])
        recent = list(base.order_by('-created_at')[:6])
        if popular or recent:
            home_sections.append({
                'value': value,
                'label': label,
                'popular': popular,
                'recent': recent,
            })

    # Two categories share each row (compact cards leave room for it).
    home_rows = [
        home_sections[i:i + 2] for i in range(0, len(home_sections), 2)
    ]

    announcements = (
        Announcement.objects.filter(published=True)
        .annotate(comments_count=Count('comments'))
        .order_by('-created_at', '-pk')[:4]
    )
    announcements_total = Announcement.objects.filter(published=True).count()
    global_read_ids = set()
    notifications = []
    unread_notifications = 0
    if request.user.is_authenticated:
        global_read_ids = set(
            AnnouncementRead.objects.filter(user=request.user)
            .values_list('announcement_id', flat=True)
        )
        notifications = list(Notification.objects.filter(user=request.user)[:5])
        unread_notifications = (
            Notification.objects.filter(user=request.user, read=False).count()
        )

    context = {
        'home_rows': home_rows,
        'stats': {
            'creators': creators,
            'packs': Project.objects.count(),
            'downloads': total_downloads,
            'versions': Version.objects.count(),
        },
        'announcements': announcements,
        'announcements_total': announcements_total,
        'global_read_ids': global_read_ids,
        'notifications': notifications,
        'unread_notifications': unread_notifications,
    }
    return render(request, 'landing/home.html', context)


def _fmt_bytes(n):
    """Human-readable byte count."""
    if n is None:
        return '—'
    size = float(n)
    for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
        if size < 1024 or unit == 'TB':
            return f'{size:.1f} {unit}'
        size /= 1024


def _folder_usage(path):
    """Sum the allocated bytes of every file under ``path`` (like ``du -s``).

    Uses st_blocks (ext4/Unix allocation) when available, otherwise the
    apparent file size. Returns None if the path doesn't exist or can't be
    walked."""
    root = Path(path)
    if not root.exists():
        return None
    total = 0
    try:
        for dirpath, dirnames, filenames in os.walk(root):
            for name in filenames:
                try:
                    st = os.stat(os.path.join(dirpath, name))
                except OSError:
                    continue
                blocks = getattr(st, 'st_blocks', 0)
                total += blocks * 512 if blocks else st.st_size
    except OSError:
        return None
    return total


def _system_snapshot():
    """Collect a one-shot system/performance snapshot via psutil."""
    try:
        import psutil
    except ImportError:
        return None
    snapshot = {}
    try:
        boot_ts = psutil.boot_time()
        snapshot['boot_time'] = tz.datetime.fromtimestamp(
            boot_ts, tz.get_current_timezone()
        ).isoformat(timespec='seconds')
        snapshot['uptime'] = round(time.time() - boot_ts)
        try:
            snapshot['load_avg'] = [round(x, 2) for x in psutil.getloadavg()]
        except Exception:
            snapshot['load_avg'] = None
        snapshot['cpus'] = {
            'logical': psutil.cpu_count(logical=True) or 0,
            'physical': psutil.cpu_count(logical=False) or 0,
            'percent': psutil.cpu_percent(interval=0.1),
        }
        vm = psutil.virtual_memory()
        snapshot['memory'] = {
            'total': vm.total, 'used': vm.used,
            'available': vm.available, 'percent': vm.percent,
        }
        sw = psutil.swap_memory()
        snapshot['swap'] = {'total': sw.total, 'used': sw.used, 'percent': sw.percent}

        snapshot['disks'] = {}
        for label, path in (('Root /', '/'), ('Code', str(settings.BASE_DIR)), ('Media', str(settings.MEDIA_ROOT))):
            try:
                d = psutil.disk_usage(path)
            except OSError:
                snapshot['disks'][label] = None
                continue
            # For folders (Code/Media) report the space the folder itself
            # takes (like `du`), falling back to the whole-drive usage when the
            # tree can't be measured. Root / always shows the drive.
            used = d.used
            scope = 'drive'
            if label != 'Root /':
                folder = _folder_usage(path)
                if folder is not None:
                    used = folder
                    scope = 'folder'
            snapshot['disks'][label] = {
                'path': path, 'total': d.total, 'used': used,
                'free': d.free, 'scope': scope,
                'percent': round(used / d.total * 100, 1) if d.total else 0,
            }

        try:
            snapshot['net'] = {
                'sent': psutil.net_io_counters().bytes_sent,
                'recv': psutil.net_io_counters().bytes_recv,
            }
        except Exception:
            snapshot['net'] = None

        running = 0
        top_cpu = []
        top_mem = []
        gunicorn = 0
        for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'cpu_percent', 'memory_percent']):
            try:
                running += 1
                info = proc.info
                cmd = ' '.join(info.get('cmdline') or [])
                if 'gunicorn' in cmd:
                    gunicorn += 1
                top_cpu.append({
                    'name': info.get('name') or '?', 'pid': info.get('pid'),
                    'value': round(info.get('cpu_percent') or 0, 1),
                })
                top_mem.append({
                    'name': info.get('name') or '?', 'pid': info.get('pid'),
                    'value': round(info.get('memory_percent') or 0, 1),
                })
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                continue
        snapshot['processes'] = running
        snapshot['gunicorn'] = gunicorn
        top_cpu.sort(key=lambda x: x['value'], reverse=True)
        top_mem.sort(key=lambda x: x['value'], reverse=True)
        snapshot['top_cpu'] = top_cpu[:6]
        snapshot['top_mem'] = top_mem[:6]
    except Exception:
        return None
    return snapshot


@login_required
def server_stats(request):
    """Staff-only stats page. Renders a lightweight shell; the snapshot data
    is fetched from the JSON endpoint so page loads stay cheap."""
    if not request.user.is_staff:
        return HttpResponseForbidden('Only staff can view server stats.')
    return render(request, 'landing/server_stats.html', {
        'disk_labels': ['Root /', 'Code', 'Media'],
    })


STATS_CACHE_KEY = 'stats:payload'
STATS_CACHE_TTL = 20
GUNICORN_ACCESS_LOG = Path(settings.BASE_DIR).parent / 'logs' / 'gunicorn-access.log'

_ACCESS_LOG_LINE = re.compile(
    r'^(?P<host>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<path>\S+)(?: \S+)?" '
    r'(?P<status>\d{3}) (?P<bytes>\S+) (?P<dur>\d+\.\d+)$'
)


def _traffic_stats():
    """Parse the tail of gunicorn's access log for live traffic metrics.

    Returns a dict, or None when the log isn't configured/missing (dev server).
    The gunicorn access-log format is set in run_prod.sh:
        %(h)s %(t)s "%(r)s" %(s)s %(b)s %(L)s
    """
    path = GUNICORN_ACCESS_LOG
    if not path.exists():
        return None
    entries = []
    try:
        with open(path, 'rb') as f:
            size = f.seek(0, 2)
            chunk = max(0, size - 1_048_576)
            f.seek(chunk)
            if chunk:
                f.readline()  # drop a possibly-split leading line
            data = f.read().decode('utf-8', 'replace')
    except OSError:
        return None

    lines = data.splitlines()[-2000:]
    for line in lines:
        m = _ACCESS_LOG_LINE.match(line)
        if not m:
            continue
        try:
            ts = tz.datetime.strptime(m.group('time'), '%d/%b/%Y:%H:%M:%S %z')
        except ValueError:
            continue
        try:
            dur_ms = round(float(m.group('dur')) * 1000, 1)
        except ValueError:
            dur_ms = None
        entries.append({
            'ts': ts,
            'method': m.group('method'),
            'path': m.group('path'),
            'status': int(m.group('status')),
            'dur_ms': dur_ms,
        })
    if not entries:
        return None

    now = tz.now()
    cutoff = now - timedelta(minutes=1)
    recent_minute = [e for e in entries if e['ts'] >= cutoff]
    durations = [e['dur_ms'] for e in entries if e['dur_ms'] is not None]

    status = {'2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0}
    for e in entries:
        code = e['status']
        if 200 <= code < 300:
            status['2xx'] += 1
        elif 300 <= code < 400:
            status['3xx'] += 1
        elif 400 <= code < 500:
            status['4xx'] += 1
        else:
            status['5xx'] += 1

    recent = [
        {
            'time': e['ts'].isoformat(timespec='seconds'),
            'method': e['method'],
            'path': e['path'],
            'status': e['status'],
            'dur_ms': e['dur_ms'],
        }
        for e in entries[-10:][::-1]
    ]

    return {
        'requests_total': len(entries),
        'requests_min': len(recent_minute),
        'avg_ms': round(statistics.fmean(durations), 1) if durations else None,
        'p50_ms': round(statistics.median(durations), 1) if durations else None,
        'max_ms': round(max(durations), 1) if durations else None,
        'status': status,
        'recent': recent,
    }



@login_required
def server_stats_api(request):
    """JSON snapshot of server state / performance / recent activity.

    The payload is cached server-side for STATS_CACHE_TTL seconds so polling
    clients share one computation per interval instead of hammering the DB."""
    if not request.user.is_staff:
        return JsonResponse({'detail': 'Only staff can view server stats.'}, status=403)
    payload = cache.get(STATS_CACHE_KEY)
    if payload is None:
        start = time.time()
        payload = _collect_stats()
        payload['app']['page_gen_ms'] = round((time.time() - start) * 1000, 1)
        cache.set(STATS_CACHE_KEY, payload, STATS_CACHE_TTL)
    return JsonResponse(payload)


def _collect_stats(page_gen_ms=None):
    """One-shot, JSON-safe snapshot of server state and recent activity."""
    now = tz.now()
    users = get_user_model().objects

    category_breakdown = dict(
        Project.objects.values_list('category').annotate(n=Count('pk')).order_by()
    )
    project_categories = [c for c, _ in Project.CATEGORY_CHOICES]
    categories_list = [
        [c, category_breakdown.get(c, 0)] for c in project_categories
    ]

    try:
        import importlib.metadata as _md
        gunicorn_version = _md.version('gunicorn')
    except Exception:
        gunicorn_version = 'unknown'

    content = {
        'users': users.count(),
        'staff': users.filter(is_staff=True).count(),
        'superusers': users.filter(is_superuser=True).count(),
        'active_24h': users.filter(last_login__gte=now - timedelta(hours=24)).count(),
        'active_7d': users.filter(last_login__gte=now - timedelta(days=7)).count(),
        'projects': sum(category_breakdown.get(c, 0) for c in project_categories),
        'categories': category_breakdown,
        'versions': Version.objects.count(),
        'files': FileIndex.objects.count(),
        'storage': _fmt_bytes(
            FileIndex.objects.aggregate(s=Sum('size'))['s'] or 0
        ),
        'assets': ProjectAsset.objects.count(),
        'downloads': Version.objects.aggregate(t=Sum('downloads'))['t'] or 0,
        'ratings': Rating.objects.count(),
        'ratings_positive': Rating.objects.filter(value=True).count(),
        'ratings_negative': Rating.objects.filter(value=False).count(),
        'comments': Comment.objects.count(),
        'tags': Tag.objects.count(),
        'sessions': Session.objects.filter(expire_date__gte=now).count(),
        'pending_uploads': TempUpload.objects.filter(status='pending').count(),
        'drafts': ProjectDraft.objects.count(),
    }

    def _iso(dt):
        return dt.isoformat(timespec='seconds')

    recent = {
        'versions': [
            {
                'project': v.project.title,
                'slug': v.project.slug,
                'version': v.version_name,
                'created_at': _iso(v.created_at),
            }
            for v in Version.objects.select_related('project').order_by('-created_at')[:8]
        ],
        'comments': [
            {
                'user': c.user.username,
                'project': c.project.title,
                'slug': c.project.slug,
                'body': c.body,
                'created_at': _iso(c.created_at),
            }
            for c in Comment.objects.select_related('user', 'project').order_by('-created_at')[:8]
        ],
        'projects': [
            {
                'title': p.title,
                'slug': p.slug,
                'category': p.category,
                'owner': p.owner.username,
                'created_at': _iso(p.created_at),
            }
            for p in Project.objects.select_related('owner').order_by('-created_at')[:5]
        ],
        'ratings': [
            {
                'user': r.user.username,
                'project': r.project.title,
                'slug': r.project.slug,
                'value': r.value,
                'created_at': _iso(r.created_at),
            }
            for r in Rating.objects.select_related('user', 'project').order_by('-created_at')[:5]
        ],
    }

    app = {
        'django': django_version(),
        'python': platform.python_version(),
        'hostname': socket.gethostname(),
        'app_env': 'dev' if settings.DEBUG else 'prod',
        'debug': settings.DEBUG,
        'git': get_git_commit_hash(),
        'db': settings.DATABASES['default']['ENGINE'].rsplit('.', 1)[-1],
        'tz': settings.TIME_ZONE,
        'media_root': str(settings.MEDIA_ROOT),
        'gunicorn': gunicorn_version,
        'page_gen_ms': page_gen_ms,
    }

    return {
        'generated_at': _iso(now),
        'system': _system_snapshot(),
        'app': app,
        'content': content,
        'categories_list': categories_list,
        'project_categories': project_categories,
        'traffic': _traffic_stats(),
        'recent': recent,
    }