import json
import mimetypes
import posixpath
import re
import zipfile
from pathlib import Path
from uuid import UUID

from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Count, F, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.text import slugify

from common.markdown import render_markdown
from .forms import ContributorForm, ProjectForm, VersionForm
from .models import (
    Comment,
    FileIndex,
    Project,
    ProjectAsset,
    ProjectContributor,
    ProjectDraft,
    Rating,
    Tag,
    TagCategory,
    TagList,
    TempUpload,
    Version,
    VersionFile,
    slugify_tag,
)
from .storage import delete_file_index, move_file_index, refresh_file_index, store_temp_file
from announcements.models import Notification
from announcements.notifications import notify_project_change
from .zips import (
    inject_animationframework,
    parse_mods_manifest,
    read_animation_manifest,
    read_logical_manifest,
    read_models_manifest,
    read_mod_version_manifest,
    read_pack_meta,
    read_pack_mcmeta,
    read_plugins_manifest,
    read_skins_manifest,
    _mc_from_filename,
    _png_size,
    SKIN_SIZES,
)


def _open_indexed_file(file_index):
    """Return an open binary file handle for the indexed file (404-safe)."""
    stored = file_index.stored_path
    if not stored or posixpath.isabs(stored) or '..' in stored.split('/'):
        raise Http404
    file_path = (Path(settings.MEDIA_ROOT) / stored).resolve()
    if not file_path.is_relative_to(Path(settings.MEDIA_ROOT).resolve()):
        raise Http404
    if not file_path.is_file():
        raise Http404
    return open(file_path, 'rb')


def _unsatisfiable(content_length):
    resp = HttpResponse(status=416)
    resp['Content-Range'] = f'bytes */{content_length}'
    return resp


def file_request(request, file_id):
    """Serve an indexed file behind the gate, honouring HTTP byte ranges.

    GET /api/files/<uuid>/                  → full file (200)
    Range: bytes=0-99                       → partial content (206)
    Range: bytes=<start>-                   → tail from <start>
    Range: bytes=-<suffix>                  → last <suffix> bytes
    ?download=1                             → Content-Disposition: attachment
    """
    file_index = get_object_or_404(FileIndex, uuid=file_id)
    fh = _open_indexed_file(file_index)

    content_type = file_index.content_type or 'application/octet-stream'
    content_length = file_index.size or 0
    disposition = ''
    if request.GET.get('download') == '1':
        filename = file_index.original_filename or posixpath.basename(file_index.stored_path)
        disposition = f'attachment; filename="{filename}"'

    range_header = request.META.get('HTTP_RANGE', '').strip()
    if not range_header or not range_header.lower().startswith('bytes='):
        response = FileResponse(fh, content_type=content_type)
        response['Accept-Ranges'] = 'bytes'
        response['Content-Length'] = str(content_length)
        if disposition:
            response['Content-Disposition'] = disposition
        return response

    try:
        start, _, end = range_header[6:].partition('-')
        start = int(start) if start else None
        end = int(end) if end else None
    except ValueError:
        return _unsatisfiable(content_length)

    if start is None and end is None:
        return _unsatisfiable(content_length)

    if start is None:
        suffix = end
        if suffix <= 0:
            return _unsatisfiable(content_length)
        start = max(content_length - suffix, 0)
        end = content_length - 1
    elif end is None:
        end = content_length - 1

    if start < 0 or start >= content_length or end < start:
        return _unsatisfiable(content_length)

    end = min(end, content_length - 1)
    length = end - start + 1
    fh.seek(start)

    def chunk_generator():
        try:
            remaining = length
            while remaining > 0:
                data = fh.read(min(8192, remaining))
                if not data:
                    break
                remaining -= len(data)
                yield data
        finally:
            fh.close()

    part = StreamingHttpResponse(chunk_generator(), content_type=content_type)
    part['Content-Range'] = f'bytes {start}-{end}/{content_length}'
    part['Accept-Ranges'] = 'bytes'
    part['Content-Length'] = str(length)
    part.status_code = 206
    if disposition:
        part['Content-Disposition'] = disposition
    return part


def _require_can_edit(request, project):
    if not project.can_edit(request.user):
        raise HttpResponseForbidden('You do not have permission to edit this project.')
    return True


def _unique_project_slug(title):
    base = slugify(title)[:100] or 'project'
    slug, n = base, 1
    while Project.objects.filter(slug=slug).exists():
        n += 1
        slug = f'{base}-{n}'
    return slug


def _tags_from_text(text):
    """Parse a raw tag string ('general:furry loader:fabric 1.20.1') into
    (category_slug, tag_name) tuples. Unprefixed names default to general."""
    pairs = []
    for part in (text or '').replace(',', ' ').split():
        if ':' in part:
            category_slug, _, name = part.partition(':')
        else:
            category_slug, name = 'general', part
        name = slugify_tag(name)
        if not name or not category_slug:
            continue
        pairs.append((category_slug, name))
    return pairs


def _managed_creator_names(project):
    """Slugified usernames of the owner + contributors — the creator tags that
    sync_creator_tags manages automatically. Creator tags that are NOT backed
    by an account (e.g. an offline artist) are treated as manual and stay
    editable/removable."""
    names = {slugify_tag(project.owner.username)}
    names.update(
        slugify_tag(c.user.username)
        for c in project.contributors.select_related('user')
    )
    return names


def _apply_tags(project, raw_tags, actor):
    """Replace a project's manual tags (auto-creating new ones). Account-backed
    creator tags (owner + contributors) are managed by sync_creator_tags and
    preserved; any other creator tag is editable like any other manual tag."""
    creator_cat = TagCategory.objects.filter(slug='creator').first()
    to_delete = project.tag_links.all()
    if creator_cat is not None:
        to_delete = to_delete.exclude(
            tag__category=creator_cat,
            tag__name__in=_managed_creator_names(project),
        )
    to_delete.delete()

    for category_slug, name in _tags_from_text(raw_tags):
        category = TagCategory.objects.filter(slug=category_slug).first()
        if category is None:
            category = TagCategory.objects.filter(slug='general').first()
        if category is None:
            continue
        tag, _ = Tag.objects.get_or_create(
            name=name, category=category, defaults={'created_by': actor},
        )
        TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': actor})


def _current_tags(project):
    """Serialize a project's editable tags back to 'category:name' form.

    Account-backed creator tags (owner + contributors) are auto-managed and
    hidden; manual creator tags (no matching account) remain visible so they
    can be removed."""
    creator_cat = TagCategory.objects.filter(slug='creator').first()
    qs = project.tag_links.select_related('tag__category').all()
    if creator_cat is not None:
        qs = qs.exclude(
            tag__category=creator_cat,
            tag__name__in=_managed_creator_names(project),
        )
    return ' '.join(f'{tl.tag.category.slug}:{tl.tag.name}' for tl in qs)


def browse(request):
    q = request.GET.get('q', '').strip()
    category = request.GET.get('category', '').strip()
    sort = request.GET.get('sort', 'recent')
    tag_names = [slugify_tag(t) for t in request.GET.getlist('tag') if t.strip()]

    projects = (
        Project.objects.select_related('owner', 'thumbnail')
        .prefetch_related('tag_links__tag__category', 'versions')
    )

    if q:
        projects = projects.filter(
            Q(title__icontains=q)
            | Q(summary__icontains=q)
            | Q(description__icontains=q)
            | Q(tag_links__tag__name__icontains=q)
        ).distinct()

    if category and category in dict(Project.CATEGORY_CHOICES):
        projects = projects.filter(category=category)
    else:
        category = ''

    for name in tag_names:
        projects = projects.filter(tag_links__tag__name=name)

    if sort == 'downloads':
        projects = projects.annotate(_downloads=Sum('versions__downloads')).order_by(
            F('_downloads').desc(nulls_last=True)
        )
    elif sort == 'most_liked':
        projects = projects.order_by('-rating_score')
    elif sort == 'most_voted':
        projects = projects.order_by('-rating_count')
    elif sort == 'name':
        projects = projects.order_by('title')
    else:
        projects = projects.order_by('-created_at')

    # Preserve the query params when toggling filters.
    def _qs(**overrides):
        params = request.GET.copy()
        for key, value in overrides.items():
            if value is None:
                params.pop(key, None)
            else:
                params.setlist(key, value if isinstance(value, list) else [value])
        return params.urlencode()

    category_menu = [('', 'All', f'?{_qs(category=None)}')]
    for value, label in Project.CATEGORY_CHOICES:
        category_menu.append((value, label, f'?{_qs(category=value)}'))

    tag_groups = []
    for cat in TagCategory.objects.all().order_by('slug'):
        tags = (
            Tag.objects.filter(category=cat)
            .annotate(count=Count('project_links'))
            .filter(count__gt=0)
            .order_by('-count')[:25]
        )
        if not tags:
            continue
        items = []
        has_active = False
        for tag in tags:
            active = tag.name in tag_names
            has_active = has_active or active
            if active:
                remaining = [t for t in tag_names if t != tag.name]
                url = f'?{_qs(tag=remaining)}'
            else:
                url = f'?{_qs(tag=tag_names + [tag.name])}'
            items.append({
                'name': tag.name,
                'color': cat.color,
                'count': tag.count,
                'active': active,
                'url': url,
            })
        tag_groups.append({'category': cat, 'items': items, 'has_active': has_active})

    paginator = Paginator(projects, 12)
    page_obj = paginator.get_page(request.GET.get('page'))

    context = {
        'projects': page_obj,
        'q': q,
        'category': category,
        'sort': sort,
        'active_tags': tag_names,
        'category_menu': category_menu,
        'tag_groups': tag_groups,
        'query_string': _qs(),
    }
    return render(request, 'library/browse.html', context)


def project_detail(request, slug):
    project = get_object_or_404(
        Project.objects.select_related('owner', 'thumbnail'), slug=slug,
    )
    versions = project.versions.prefetch_related('files__file').all()
    assets = project.assets.select_related('file').all()
    tags = project.tag_links.select_related('tag__category').all()
    can_edit = project.can_edit(request.user)

    guide_docs = []
    animation_manifest = None
    models_manifest = None
    logical_manifest = None
    mods_manifest = None
    plugins_manifest = None
    skins_manifest = None
    mod_manifest = None
    anim_stats = None
    latest = versions.first()
    if project.category == 'guide' and latest is not None:
        guide_docs = []
        seen = set()
        for vf in latest.files.all():
            if vf.is_markdown:
                guide_docs.append({
                    'filename': vf.filename,
                    'url': reverse('library:guide_doc', args=[project.slug, latest.pk, vf.file.uuid]),
                })
                seen.add((vf.filename or '').lower())
        release = latest.files.filter(kind='release').first()
        if release is not None and (release.filename or '').lower().endswith('.zip'):
            try:
                with zipfile.ZipFile(_stored_path(release.file)) as zf:
                    members = [
                        m for m in zf.namelist()
                        if not m.endswith('/') and m.lower().endswith('.md')
                    ]
            except (zipfile.BadZipFile, OSError, FileNotFoundError):
                members = []
            members.sort(key=lambda m: (
                not _guide_norm_path(m).rsplit('/', 1)[-1].startswith(('readme', 'index')),
                m,
            ))
            for m in members:
                bname = _guide_norm_path(m).rsplit('/', 1)[-1]
                if bname in seen:
                    continue
                seen.add(bname)
                guide_docs.append({
                    'filename': bname,
                    'url': reverse('library:guide_zip_doc', args=[project.slug, latest.pk, m]),
                })
    if latest is not None:
        animation_manifest = latest.animation_manifest or None
        models_manifest = latest.models_manifest or None
        logical_manifest = latest.logical_manifest or None
        mods_manifest = latest.mods_manifest or None
        plugins_manifest = latest.plugins_manifest or None
        skins_manifest = latest.skins_manifest or None
        if skins_manifest and latest.pk:
            # Older manifests lack a per-skin url; fall back to the zip member.
            for skin in skins_manifest.get('skins', []):
                if not skin.get('url') and skin.get('member'):
                    skin['url'] = reverse(
                        'library:pack_asset',
                        args=[project.slug, latest.pk, skin['member']],
                    )
        mod_manifest = latest.mod_manifest or None
    if animation_manifest:
        anims = animation_manifest.get('animations') or []
        entity_count = sum(1 for a in anims if a.get('type') == 'Entity x Player')
        solo_count = sum(1 for a in anims if a.get('type') == 'Solo')
        anim_stats = {
            'total': len(anims),
            'entity': entity_count,
            'player': len(anims) - entity_count - solo_count,
            'solo': solo_count,
            'flagged': sum(1 for a in anims if a.get('problem_tags')),
            'tags': sorted({t for a in anims for t in (a.get('content_tags') or [])}),
        }

    mod_loaders = []
    mod_count = 0
    if mod_manifest:
        mods = mod_manifest.get('mods') or []
        mod_count = len(mods)
        for m in mods:
            loader = m.get('modloader')
            if loader and loader not in mod_loaders:
                mod_loaders.append(loader)

    # Ratings + forum.
    rating_agg = project.ratings.aggregate(
        positive=Count('pk', filter=Q(value=True)),
        total=Count('pk'),
    )
    rating_positive = rating_agg['positive'] or 0
    rating_total = rating_agg['total'] or 0
    rating_negative = rating_total - rating_positive
    rating_percent = round(rating_positive / rating_total * 100) if rating_total else 0
    user_rating = None
    if request.user.is_authenticated:
        user_rating = project.ratings.filter(user=request.user).values_list('value', flat=True).first()
    comments = project.comments.select_related('user').order_by('-created_at')[:200]
    can_comment = request.user.is_authenticated

    # Visiting the UGC page marks the user's personal notifications for it read.
    if request.user.is_authenticated:
        Notification.objects.filter(
            user=request.user, project=project, read=False,
        ).update(read=True)

    autoload_plugins_json = ''
    if plugins_manifest:
        members = [
            p['member'] for p in plugins_manifest.get('plugins', [])
            if p.get('autoload') and p.get('member')
        ]
        autoload_plugins_json = json.dumps(members)

    context = {
        'project': project,
        'versions': versions,
        'assets': assets,
        'tags': tags,
        'can_edit': can_edit,
        'guide_docs': guide_docs,
        'animation_manifest': animation_manifest,
        'models_manifest': models_manifest,
        'logical_manifest': logical_manifest,
        'mods_manifest': mods_manifest,
        'plugins_manifest': plugins_manifest,
        'skins_manifest': skins_manifest,
        'mod_manifest': mod_manifest,
        'mod_loaders': mod_loaders,
        'mod_count': mod_count,
        'rating_stats': {
            'positive': rating_positive,
            'negative': rating_negative,
            'total': rating_total,
            'percent': rating_percent,
            'negative_percent': 100 - rating_percent,
        },
        'user_rating': user_rating,
        'comments': comments,
        'can_comment': can_comment,
        'autoload_plugins_json': autoload_plugins_json,
        'anim_stats': anim_stats,
    }
    return render(request, 'library/project_detail.html', context)


def pack_asset(request, slug, version_id, asset_path):
    """Serve a named member from a stored version zip (gated), used for the
    Models/Textures + Logical tabs and the model viewer."""
    version = get_object_or_404(
        Version.objects.select_related('project'),
        pk=version_id, project__slug=slug,
    )
    if not asset_path or asset_path.startswith('/') or '..' in asset_path.split('/'):
        raise Http404
    vf = (
        version.files.select_related('file').filter(kind='release').first()
        or version.files.select_related('file').first()
    )
    if vf is None:
        raise Http404
    with zipfile.ZipFile(_stored_path(vf.file)) as zf:
        if asset_path not in zf.namelist():
            raise Http404
        data = zf.read(asset_path)
    content_type = mimetypes.guess_type(asset_path)[0] or 'application/octet-stream'
    return HttpResponse(data, content_type=content_type)


@login_required
def api_packs_latest(request, namespace, pack_id):
    """Auto-updater endpoint: newest version for a NoN pack id (namespace:pack_id)."""
    animation_id = f'{namespace}:{pack_id}'
    version = (
        Version.objects.filter(animation_id=animation_id)
        .select_related('project').prefetch_related('files__file')
        .order_by('-created_at').first()
    )
    if version is None:
        return JsonResponse({'detail': 'Not found'}, status=404)
    release = next(
        (vf for vf in version.files.all() if vf.kind == 'release'),
        version.files.first(),
    )
    if release is None:
        return JsonResponse({'detail': 'No file'}, status=404)
    download_url = request.build_absolute_uri(
        reverse('library:file_request', args=[release.file.uuid]) + '?download=1'
    )
    return JsonResponse({
        'namespace': namespace,
        'pack_id': pack_id,
        'name': version.project.title,
        'version': version.version_name,
        'pack_format': version.pack_format,
        'created_at': version.created_at.isoformat(),
        'download_url': download_url,
    })


_GUIDE_LINK_RE = re.compile(r'(!?\[[^\]]*\]\()([^\s)]+)([^)]*)(\))')
_GUIDE_REF_RE = re.compile(r'^(\[[^\]]+\]:\s*)(\S+)\s*$', re.MULTILINE)
_GUIDE_LINK_SKIP = ('http://', 'https://', '//', 'data:', 'mailto:', '#', 'tel:', 'javascript:')


def _guide_norm_path(path):
    """Normalize a link target / stored path for matching (lowercase, no ./ or /)."""
    p = (path or '').strip().replace('\\', '/')
    while p.startswith('./'):
        p = p[2:]
    p = p.lstrip('/')
    return p.lower()


def _guide_asset_maps(version, base_dir=None, source='zip'):
    """Build normalized path/basename -> served URL maps for a guide version.

    ``full_map`` keys are normalized full paths, ``base_map`` keys are
    normalized basenames. First match wins; priority is: members inside the
    current doc's own zip directory -> standalone uploaded files -> other zip
    members. Markdown members map to the rendered-doc endpoints, everything
    else to the raw file/asset endpoints."""
    slug = version.project.slug
    full = {}
    base = {}

    def _add(path, url):
        norm = _guide_norm_path(path)
        full.setdefault(norm, url)
        bname = norm.rsplit('/', 1)[-1]
        if bname and bname not in base:
            base[bname] = url

    release = version.files.filter(kind='release').first()
    zip_members = []
    if release is not None and (release.filename or '').lower().endswith('.zip'):
        try:
            with zipfile.ZipFile(_stored_path(release.file)) as zf:
                zip_members = [m for m in zf.namelist() if not m.endswith('/')]
        except (zipfile.BadZipFile, OSError, FileNotFoundError):
            zip_members = []

    def _zip_url(member):
        if member.lower().endswith('.md'):
            return reverse('library:guide_zip_doc', args=[slug, version.pk, member])
        return reverse('library:pack_asset', args=[slug, version.pk, member])

    def _under_base(member):
        if not base_dir:
            return False
        norm = _guide_norm_path(member)
        return norm == base_dir or norm.startswith(base_dir + '/')

    if source == 'zip':
        for m in zip_members:
            if _under_base(m):
                _add(m, _zip_url(m))
    for vf in version.files.all():
        name = (vf.filename or '').replace('\\', '/')
        if not name or (vf.kind == 'release' and name.lower().endswith('.zip')):
            continue
        if vf.is_markdown:
            url = reverse('library:guide_doc', args=[slug, version.pk, vf.file.uuid])
        else:
            url = reverse('library:file_request', args=[vf.file.uuid])
        _add(name, url)
    for m in zip_members:
        if not (source == 'zip' and _under_base(m)):
            _add(m, _zip_url(m))
    return full, base


def _resolve_guide_src(src, full_map, base_map, base_dir=None):
    """Rewrite one markdown target to its served URL, or None when unknown."""
    if not src or src.startswith(_GUIDE_LINK_SKIP):
        return None
    path = src.split('#')[0].split('?')[0]
    if not path:
        return None
    norm = _guide_norm_path(path)
    if base_dir:
        cand = _guide_norm_path(f'{base_dir}/{path}')
        if cand in full_map:
            return full_map[cand]
    if norm in full_map:
        return full_map[norm]
    bname = norm.rsplit('/', 1)[-1]
    if bname in base_map:
        return base_map[bname]
    return None


def _rewrite_guide_links(md, version, base_dir=None, source='zip'):
    """Rewrite markdown link/image/reference targets to served URLs whenever a
    file with the same name is present in the version's uploads (individual
    files or the release zip). Guide category only."""
    full_map, base_map = _guide_asset_maps(version, base_dir=base_dir, source=source)

    def _sub_inline(m):
        new = _resolve_guide_src(m.group(2), full_map, base_map, base_dir)
        return f'{m.group(1)}{new or m.group(2)}{m.group(3)}{m.group(4)}'

    def _sub_ref(m):
        new = _resolve_guide_src(m.group(2), full_map, base_map, base_dir)
        return f'{m.group(1)}{new or m.group(2)}'

    md = _GUIDE_LINK_RE.sub(_sub_inline, md)
    md = _GUIDE_REF_RE.sub(_sub_ref, md)
    return md


def guide_doc(request, slug, version_id, file_uuid):
    """Render one markdown guide document server-side (used by the switcher)."""
    version = get_object_or_404(
        Version.objects.select_related('project'),
        pk=version_id, project__slug=slug,
    )
    vf = get_object_or_404(
        version.files.select_related('file'), file__uuid=file_uuid,
    )
    if not vf.is_markdown:
        raise Http404
    fh = _open_indexed_file(vf.file)
    try:
        text = fh.read().decode('utf-8', 'replace')
    finally:
        fh.close()
    if version.project.category == 'guide':
        text = _rewrite_guide_links(text, version, source='file')
    return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8')


def guide_zip_doc(request, slug, version_id, asset_path):
    """Render one markdown document stored inside the version's release zip."""
    version = get_object_or_404(
        Version.objects.select_related('project'),
        pk=version_id, project__slug=slug,
    )
    if version.project.category != 'guide':
        raise Http404
    if not asset_path or asset_path.startswith('/') or '..' in asset_path.split('/'):
        raise Http404
    if not asset_path.lower().endswith('.md'):
        raise Http404
    vf = (
        version.files.select_related('file').filter(kind='release').first()
        or version.files.select_related('file').first()
    )
    if vf is None or not (vf.filename or '').lower().endswith('.zip'):
        raise Http404
    with zipfile.ZipFile(_stored_path(vf.file)) as zf:
        if asset_path not in zf.namelist():
            raise Http404
        data = zf.read(asset_path)
    text = data.decode('utf-8', 'replace')
    text = _rewrite_guide_links(
        text, version,
        base_dir=posixpath.dirname(asset_path), source='zip',
    )
    return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8')


DRAFT_KEYS = (
    'title', 'summary', 'category', 'description',
    'tags', 'version_name', 'changelog',
)


def _serialize_temp(upload):
    return {
        'uuid': str(upload.uuid),
        'kind': upload.kind,
        'filename': upload.file.original_filename,
        'content_type': upload.file.content_type,
        'url': reverse('library:file_request', args=[upload.file.uuid]),
    }


def _pending_uploads(user, draft_uuid=None):
    qs = TempUpload.objects.filter(user=user, status='pending')
    if draft_uuid is not None:
        qs = qs.filter(draft_uuid=draft_uuid)
    else:
        qs = qs.filter(draft_uuid__isnull=True)
    return list(qs.select_related('file').order_by('created_at'))


def _pending_kind(user, kind, draft_uuid=None):
    qs = TempUpload.objects.filter(user=user, status='pending', kind=kind)
    if draft_uuid is not None:
        qs = qs.filter(draft_uuid=draft_uuid)
    else:
        qs = qs.filter(draft_uuid__isnull=True)
    return list(qs.select_related('file').order_by('created_at'))


def _latest_pending(user, kind, draft_uuid=None):
    qs = TempUpload.objects.filter(user=user, status='pending', kind=kind)
    if draft_uuid is not None:
        qs = qs.filter(draft_uuid=draft_uuid)
    else:
        qs = qs.filter(draft_uuid__isnull=True)
    return qs.select_related('file').order_by('created_at').last()


def _adopt_temp(upload, project_pk, subdir, kind):
    """Move a temp upload under project_<pk>/ and return the FileIndex."""
    base = posixpath.basename(upload.file.stored_path)
    new_path = f'project_{project_pk}/{subdir}/{base}' if subdir else f'project_{project_pk}/{base}'
    return move_file_index(upload.file, new_path, kind=kind)


def _classify_version_file(file_index, category):
    """Pick a VersionFile.kind from the uploaded file's extension."""
    name = (file_index.original_filename or '').lower()
    if name.endswith('.md'):
        return 'markdown'
    if name.endswith('.png'):
        return 'png'
    return 'release'


def _stored_path(file_index):
    """Resolve an indexed file's stored_path to an absolute filesystem path."""
    return Path(settings.MEDIA_ROOT) / file_index.stored_path


ANIMATION_API_WARNING = (
    'Warning: your pack is incompatible with the update-check API endpoint and '
    'may not work with applications that check for updates. Add an '
    "'animationframework' block to pack.mcmeta, e.g.: "
    '{"pack":{"pack_format":64},"animationframework":{"id":"yourname:yourpack",'
    '"name":"Your Pack","author":"You","version":"1.0.0","description":"..."}}'
)


def _clean_mc_version(value):
    """Extract the first X.Y from an MC version/range ('>=26.2 <26.3' -> '26.2')."""
    m = re.search(r'(\d+\.\d+)', str(value or ''))
    return m.group(1) if m else ''


def _vanilla_entities():
    """Set of real Minecraft entity names from the bundled vanilla index.

    Excludes renderer aliases (slime_size_*, player_slim) that are model
    variants rather than Minecraft entities.
    """
    aliases = {'slime_size_0', 'slime_size_1', 'slime_size_3', 'player_slim'}
    try:
        with open(settings.BASE_DIR / 'static' / 'vanilla' / 'entity_index.json') as f:
            return set(json.load(f).keys()) - aliases
    except (OSError, ValueError):
        return set()


def _species_entity(entity):
    """Map model-variant names to their real Minecraft entity."""
    return {
        'player_slim': 'player',
        'slime_size_0': 'slime',
        'slime_size_1': 'slime',
        'slime_size_3': 'slime',
    }.get(entity, entity)


def _auto_tag_specs(project, version):
    """Deduped list of (category_slug, tag_name) auto-tags for a version."""
    specs = []

    def add(cat, name):
        name = slugify_tag(name)
        if name and (cat, name) not in specs:
            specs.append((cat, name))

    if project.category == 'mod':
        for m in (version.mod_manifest or {}).get('mods', []):
            if m.get('modloader'):
                add('loader', m['modloader'])
            mc = _clean_mc_version(m.get('minecraft'))
            if not mc:
                mc = _mc_from_filename(m.get('file_name') or '')
            if mc:
                add('version', mc)
    elif project.category == 'modpack':
        for loader in (version.mods_manifest or {}).get('modloaders', []):
            add('loader', loader)
        mc = (version.mods_manifest or {}).get('minecraft')
        if mc:
            add('version', mc)
    elif project.category == 'model':
        vanilla = _vanilla_entities()
        for m in (version.models_manifest or {}).get('models', []):
            ext = (m.get('ext') or '').lstrip('.')
            if ext == 'geo.json':
                ext = 'geo'
            if ext:
                add('format', ext)
            entity = _species_entity(m.get('entity') or '')
            if entity and entity in vanilla:
                add('species', entity)
    elif project.category == 'skin':
        add('general', 'skin')
    elif project.category == 'guide':
        add('general', 'guide')
    elif project.category == 'non_pack':
        # NoN packs may carry custom/modded species — no vanilla whitelist.
        # Only skip placeholder model names (pregnancy-egg stubs, etc.).
        placeholders = {'default', 'minecraft_spider'}
        for tag in (version.animation_manifest or {}).get('content_tags', []):
            add('content', tag)
        for m in (version.models_manifest or {}).get('models', []):
            entity = _species_entity(m.get('entity') or '')
            if entity and entity not in placeholders:
                add('species', entity)
    return specs


def _sync_auto_tags(project, actor=None, version=None):
    """Apply auto tags (loader/version/format/species/general/content) from the
    given version's manifests. Marks auto links, cleans stale ones, and never
    touches manual links (arbitrary tags like creator:LiZo are preserved)."""
    actor = actor or project.owner
    version = version or project.versions.first()
    if version is None:
        return

    TagCategory.objects.get_or_create(slug='format', defaults={'name': 'Format', 'color': '#0e7490'})
    specs = _auto_tag_specs(project, version)
    spec_set = set(specs)

    cats = {}
    for cat_slug, name in specs:
        cat = cats.get(cat_slug) or TagCategory.objects.filter(slug=cat_slug).first()
        if cat is None:
            continue
        cats[cat_slug] = cat
        tag, _ = Tag.objects.get_or_create(
            name=name, category=cat, defaults={'created_by': actor},
        )
        link, created = TagList.objects.get_or_create(
            project=project, tag=tag, defaults={'added_by': actor, 'auto': True},
        )
        if not created and not link.auto:
            link.auto = True
            link.save(update_fields=['auto'])

    for link in project.tag_links.select_related('tag__category').filter(auto=True).all():
        if (link.tag.category.slug, link.tag.name) not in spec_set:
            link.delete()


def _established_animation_id(project):
    """The animationframework.id of the project's newest version (its identity)."""
    latest = project.versions.first()
    return latest.animation_id if latest is not None else ''


def _valid_id_component(value):
    return bool(re.match(r'^[a-zA-Z0-9_.-]+$', value or ''))


def _animation_fields(project, version_name, animation_id):
    return {
        'id': animation_id,
        'name': project.title,
        'author': project.owner.username,
        'version': version_name,
        'description': project.summary or project.title,
    }


def _inject_animation_metadata(request, project, version_name, temp_uploads):
    """For NoN-pack zips missing an animationframework.id, rewrite the temp zip
    to add the block. Returns (errors, injected_count)."""
    if project.category != 'non_pack':
        return [], 0
    established = _established_animation_id(project)
    default_ns = slugify(project.owner.username) or 'site'
    default_pid = f'project_{project.pk}'
    ns = (request.POST.get('af_namespace') or default_ns).strip()
    pid = (request.POST.get('af_pack_id') or default_pid).strip()
    create_pack = {
        'pack_format': 64,
        'supported_formats': [64, 81],
        'min_format': 64,
        'max_format': 81,
        'description': project.title,
    }

    errors = []
    injected = 0
    for temp in temp_uploads:
        if not (temp.file.original_filename or '').lower().endswith('.zip'):
            continue
        path = _stored_path(temp.file)
        meta = read_pack_meta(path)
        current = meta.get('animation_id') if meta else None

        if current:
            if established and current != established and not request.POST.get('confirm_replace_identity'):
                errors.append(
                    'The uploaded pack has a different animationframework.id than this project. '
                    'Check "replace pack identity" to accept it.'
                )
            continue

        desired = established or f'{ns}:{pid}'
        if not _valid_id_component(ns) or not _valid_id_component(pid):
            errors.append('Animation ID must only use letters, numbers, underscores, dots or dashes.')
            break
        if inject_animationframework(
            path, _animation_fields(project, version_name, desired), create_pack,
        ):
            refresh_file_index(temp.file)
            injected += 1
    return errors, injected


def _animation_meta_for_upload(request, project):
    """Render hints for the version-upload page's Animation ID section."""
    if project.category != 'non_pack':
        return {'show': False}
    established = _established_animation_id(project)
    default_ns = slugify(project.owner.username) or 'site'
    default_pid = f'project_{project.pk}'
    show_fields = False
    show_confirm = False
    for temp in _pending_kind(request.user, 'version'):
        if not (temp.file.original_filename or '').lower().endswith('.zip'):
            continue
        meta = read_pack_meta(_stored_path(temp.file))
        current = meta.get('animation_id') if meta else None
        if not current and not established:
            show_fields = True
        if current and established and current != established:
            show_confirm = True
    if established:
        ns, _, pid = established.partition(':')
        default_ns = ns or default_ns
        default_pid = pid or default_pid
    return {
        'show': show_fields or show_confirm,
        'show_fields': show_fields,
        'show_confirm': show_confirm,
        'established': established,
        'namespace': default_ns,
        'pack_id': default_pid,
    }


def _build_skins_manifest(version, release_path=None):
    """Skins for a version: members of the release zip plus individually
    uploaded PNG files. Each entry carries a ready-to-use ``url``."""
    skins = []
    if release_path is not None:
        manifest = read_skins_manifest(release_path)
        if manifest:
            for skin in manifest.get('skins', []):
                skin['url'] = reverse(
                    'library:pack_asset',
                    args=[version.project.slug, version.pk, skin['member']],
                )
                skins.append(skin)
    for vf in version.files.filter(kind='png').select_related('file'):
        try:
            with open(_stored_path(vf.file), 'rb') as fh:
                head = fh.read(24)
        except OSError:
            continue
        size = _png_size(head)
        if size and size in SKIN_SIZES:
            skins.append({
                'name': vf.filename,
                'member': str(vf.file.uuid),
                'width': size[0],
                'height': size[1],
                'url': reverse('library:file_request', args=[vf.file.uuid]),
            })
    if not skins:
        return {}
    return {'skins': skins}


def _reparse_version(version):
    """Re-derive a version's metadata (id + manifests) from its release zip."""
    release = version.files.filter(kind='release').first()
    path = _stored_path(release.file) if release is not None else None
    if path is None and version.project.category != 'skin':
        return
    meta = read_pack_meta(path) if path else None
    if meta and meta.get('animation_id'):
        version.animation_id = meta['animation_id']
    else:
        version.animation_id = ''
    version.animation_manifest = read_animation_manifest(path) or {} if path else {}
    version.models_manifest = read_models_manifest(path, version.project.category) or {} if path else {}
    version.logical_manifest = read_logical_manifest(path) or {} if path else {}
    version.plugins_manifest = read_plugins_manifest(path) or {} if path and version.project.category == 'model' else {}
    version.skins_manifest = _build_skins_manifest(version, path) if version.project.category == 'skin' else {}
    if version.project.category == 'mod':
        entries = [
            (vf.filename, _stored_path(vf.file))
            for vf in version.files.select_related('file').all()
            if vf.filename.lower().endswith(('.jar', '.zip'))
        ]
        version.mod_manifest = read_mod_version_manifest(entries)
    else:
        version.mod_manifest = {}
    if release is not None:
        manifest = parse_mods_manifest(path, release.file.original_filename)
        version.mods_manifest = manifest if manifest.get('files') else {}
        fmt, desc = read_pack_mcmeta(path)
        if fmt is not None:
            version.pack_format = fmt
            version.pack_description = desc
    else:
        version.mods_manifest = {}
    version.save()
    _sync_auto_tags(version.project, version.project.owner, version.project.versions.first())


def _finalize_version(project, version_name, changelog, temp_uploads, actor):
    """Adopt pending 'version' temp uploads into a new Version as VersionFiles,
    capturing category/content-specific metadata (pack.mcmeta, mods, animation,
    models, logical manifests). Returns (version, missing_animation_id)."""
    version = Version.objects.create(
        project=project,
        version_name=version_name,
        changelog=changelog,
    )
    pack_format = None
    pack_description = ''
    mods_manifest = {}
    plugins_manifest = None
    skins_manifest = None
    mod_file_entries = []
    animation_id = ''
    animation_manifest = None
    models_manifest = None
    logical_manifest = None
    missing_animation_id = False

    for temp in temp_uploads:
        index = _adopt_temp(temp, project.pk, 'versions', 'version')
        VersionFile.objects.create(
            version=version,
            file=index,
            kind=_classify_version_file(index, project.category),
        )
        temp.status = 'used'
        temp.save(update_fields=['status'])

        if project.category == 'mod':
            mod_name = (index.original_filename or '').lower()
            if mod_name.endswith(('.jar', '.zip')):
                mod_file_entries.append((index.original_filename, _stored_path(index)))

        is_archive = (index.original_filename or '').lower().endswith(('.zip', '.mrpack', '.jar'))
        if not is_archive:
            continue
        path = _stored_path(index)

        if project.category == 'non_pack' and pack_format is None:
            fmt, desc = read_pack_mcmeta(path)
            if fmt is not None:
                pack_format = fmt
                pack_description = desc

        if not animation_id:
            meta = read_pack_meta(path)
            if meta and meta.get('animation_id'):
                animation_id = meta['animation_id']

        if animation_manifest is None:
            animation_manifest = read_animation_manifest(path)
        if models_manifest is None:
            models_manifest = read_models_manifest(path, project.category)
        if logical_manifest is None:
            logical_manifest = read_logical_manifest(path)
        if not mods_manifest:
            manifest = parse_mods_manifest(path, index.original_filename)
            if manifest.get('files'):
                mods_manifest = manifest
        if plugins_manifest is None and project.category == 'model':
            plugins_manifest = read_plugins_manifest(path)

    # Skins come from the release zip members and/or individually uploaded PNGs.
    if project.category == 'skin':
        release = version.files.filter(kind='release').first()
        release_path = None
        if release is not None and (release.filename or '').lower().endswith('.zip'):
            release_path = _stored_path(release.file)
        skins_manifest = _build_skins_manifest(version, release_path)

    update_fields = []
    if pack_format is not None:
        version.pack_format = pack_format
        version.pack_description = pack_description
        update_fields += ['pack_format', 'pack_description']
    if mods_manifest:
        version.mods_manifest = mods_manifest
        update_fields.append('mods_manifest')
    if plugins_manifest is not None:
        version.plugins_manifest = plugins_manifest
        update_fields.append('plugins_manifest')
    if skins_manifest:
        version.skins_manifest = skins_manifest
        update_fields.append('skins_manifest')
    if project.category == 'mod' and mod_file_entries:
        version.mod_manifest = read_mod_version_manifest(mod_file_entries)
        update_fields.append('mod_manifest')
    if animation_id:
        version.animation_id = animation_id
        update_fields.append('animation_id')
    if animation_manifest is not None:
        version.animation_manifest = animation_manifest
        update_fields.append('animation_manifest')
        if not animation_id:
            missing_animation_id = True
    if models_manifest is not None:
        version.models_manifest = models_manifest
        update_fields.append('models_manifest')
    if logical_manifest is not None:
        version.logical_manifest = logical_manifest
        update_fields.append('logical_manifest')
    if update_fields:
        version.save(update_fields=update_fields)

    _sync_auto_tags(project, actor, version)
    return version, missing_animation_id


def _form_values(*forms):
    out = {}
    for form in forms:
        for name in form.fields:
            out[name] = form[name].value() or ''
    return out


@login_required
@login_required
def my_projects(request):
    """The user's own projects and saved drafts (two-column page)."""
    projects = Project.objects.filter(owner=request.user).order_by('-created_at')
    drafts = ProjectDraft.objects.filter(user=request.user)
    return render(request, 'library/my_projects.html', {
        'projects': projects,
        'drafts': drafts,
    })


@login_required
def project_create_new(request):
    """Entry point for a new project: create a fresh draft UUID, then edit it."""
    if request.method != 'GET':
        return HttpResponse(status=405)
    draft = ProjectDraft.objects.create(user=request.user)
    return redirect('library:project_create', uuid=draft.uuid)


@login_required
def project_draft_delete(request, uuid):
    """Delete one draft (owner only) and purge its pending uploads."""
    draft = ProjectDraft.objects.filter(uuid=uuid, user=request.user).first()
    if draft is None:
        return redirect('library:my_projects')
    if request.method == 'POST':
        for temp in TempUpload.objects.filter(draft_uuid=draft.uuid):
            delete_file_index(temp.file)
        TempUpload.objects.filter(draft_uuid=draft.uuid).delete()
        draft.delete()
        messages.success(request, 'Draft deleted.')
    return redirect('library:my_projects')


@login_required
def project_create(request, uuid):
    """Create-page for one specific draft. Only its owner may access it."""
    draft = ProjectDraft.objects.filter(uuid=uuid, user=request.user).first()
    if draft is None:
        return redirect('library:my_projects')

    project_form = ProjectForm()
    version_form = VersionForm()

    if request.method == 'POST':
        project_form = ProjectForm(request.POST)
        version_form = VersionForm(request.POST)
        if project_form.is_valid() and version_form.is_valid():
            data = project_form.cleaned_data
            version_files = _pending_kind(request.user, 'version', draft.uuid)
            if not version_files:
                version_form.add_error('version_name', 'Upload at least one version file before creating the project.')
            else:
                project = Project.objects.create(
                    slug=_unique_project_slug(data['title']),
                    title=data['title'],
                    summary=data['summary'],
                    description=data['description'],
                    category=data['category'],
                    owner=request.user,
                )

                thumb = _latest_pending(request.user, 'thumbnail', draft.uuid)
                if thumb is not None:
                    project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail')
                    thumb.status = 'used'
                    thumb.save(update_fields=['status'])
                    project.save(update_fields=['thumbnail'])

                _inject_animation_metadata(
                    request, project, version_form.cleaned_data['version_name'], version_files,
                )
                _, missing_animation_id = _finalize_version(
                    project,
                    version_form.cleaned_data['version_name'],
                    version_form.cleaned_data['changelog'],
                    version_files,
                    request.user,
                )
                if missing_animation_id and project.category == 'non_pack':
                    messages.warning(request, ANIMATION_API_WARNING)

                for temp in TempUpload.objects.filter(
                    user=request.user, status='pending', kind='media', draft_uuid=draft.uuid,
                ).select_related('file').order_by('created_at'):
                    index = _adopt_temp(temp, project.pk, 'gallery', 'asset')
                    ProjectAsset.objects.create(
                        project=project, file=index,
                        caption=request.POST.get(f'caption_{temp.uuid}', '')[:128],
                        uploaded_by=request.user,
                    )
                    temp.status = 'used'
                    temp.save(update_fields=['status'])

                _apply_tags(project, data['tags'], request.user)
                project.sync_creator_tags(actor=request.user)
                _sync_auto_tags(project, request.user)
                draft.delete()
                messages.success(request, 'Project created.')
                return redirect('library:project_detail', slug=project.slug)
    else:
        initial = {k: draft.data.get(k, '') for k in DRAFT_KEYS}
        project_form = ProjectForm(initial=initial)
        version_form = VersionForm(
            initial={
                k: initial[k] for k in ('version_name', 'changelog') if k in initial
            },
        )

    return render(request, 'library/project_create.html', {
        'project_form': project_form,
        'version_form': version_form,
        'draft_uuid': draft.uuid,
        'draft_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user, draft.uuid)],
        'draft_data': _form_values(project_form, version_form),
        'tag_categories': list(TagCategory.objects.order_by('slug').values('slug', 'color')),
    })


@login_required
def project_edit(request, slug):
    project = get_object_or_404(Project, slug=slug)
    if not project.can_edit(request.user):
        return HttpResponseForbidden('You do not have permission to edit this project.')

    if request.method == 'POST':
        form = ProjectForm(request.POST)
        if form.is_valid():
            data = form.cleaned_data
            project.title = data['title']
            project.summary = data['summary']
            project.description = data['description']
            project.category = data['category']

            thumb = _latest_pending(request.user, 'thumbnail')
            if thumb is not None:
                delete_file_index(project.thumbnail)
                project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail')
                thumb.status = 'used'
                thumb.save(update_fields=['status'])

            project.save()
            _apply_tags(project, data['tags'], request.user)
            project.sync_creator_tags(actor=request.user)
            _sync_auto_tags(project, request.user)
            messages.success(request, 'Project updated.')
            notify_project_change(
                project, request.user, 'edit',
                f'{request.user.username} edited {project.title}',
            )
            return redirect('library:project_detail', slug=project.slug)
    else:
        form = ProjectForm(initial={
            'title': project.title,
            'summary': project.summary,
            'description': project.description,
            'category': project.category,
            'tags': _current_tags(project),
        })

    thumb = _latest_pending(request.user, 'thumbnail')
    if thumb is not None:
        pending_uploads = [_serialize_temp(thumb)]
    elif project.thumbnail_id:
        pending_uploads = [{
            'uuid': str(project.thumbnail.uuid),
            'kind': 'thumbnail',
            'filename': project.thumbnail.original_filename,
            'content_type': project.thumbnail.content_type,
            'url': project.thumbnail_url,
            'removable': False,
        }]
    else:
        pending_uploads = []

    established = _established_animation_id(project)
    ns, _, pid = established.partition(':')
    anim_edit = {
        'current': established,
        'namespace': ns or (slugify(project.owner.username) or 'site'),
        'pack_id': pid or f'project_{project.pk}',
    }

    return render(request, 'library/project_edit.html', {
        'project': project,
        'project_form': form,
        'pending_uploads': pending_uploads,
        'tag_categories': list(TagCategory.objects.order_by('slug').values('slug', 'color')),
        'anim_edit': anim_edit,
    })


@login_required
def project_delete(request, slug):
    project = get_object_or_404(Project, slug=slug)
    if request.user != project.owner:
        return HttpResponseForbidden('Only the owner can delete this project.')
    if request.method == 'POST':
        indices = []
        if project.thumbnail_id:
            indices.append(project.thumbnail)
        for version in project.versions.prefetch_related('files__file').all():
            indices += [vf.file for vf in version.files.all() if vf.file_id]
        indices += [a.file for a in project.assets.all() if a.file_id]
        project.delete()
        for index in indices:
            delete_file_index(index)
        messages.success(request, 'Project deleted.')
        return redirect('library:browse')
    return render(request, 'library/project_delete.html', {'project': project})


@login_required
def version_upload(request, slug):
    project = get_object_or_404(Project, slug=slug)
    if not project.can_edit(request.user):
        return HttpResponseForbidden('You do not have permission to upload versions.')

    form = VersionForm()
    if request.method == 'POST':
        form = VersionForm(request.POST)
        if form.is_valid():
            version_files = _pending_kind(request.user, 'version')
            if not version_files:
                form.add_error('version_name', 'Upload at least one version file before uploading.')
            else:
                errors, _injected = _inject_animation_metadata(
                    request, project, form.cleaned_data['version_name'], version_files,
                )
                for error in errors:
                    form.add_error(None, error)
                if not errors:
                    _, missing_animation_id = _finalize_version(
                        project,
                        form.cleaned_data['version_name'],
                        form.cleaned_data['changelog'],
                        version_files,
                        request.user,
                    )
                    if missing_animation_id and project.category == 'non_pack':
                        messages.warning(request, ANIMATION_API_WARNING)
                    messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
                    notify_project_change(
                        project, request.user, 'version',
                        f'{request.user.username} uploaded version {form.cleaned_data["version_name"]} on {project.title}',
                    )
                    return redirect('library:project_detail', slug=project.slug)

    version_files = _pending_kind(request.user, 'version')
    return render(request, 'library/version_upload.html', {
        'project': project,
        'version_form': form,
        'pending_uploads': [_serialize_temp(u) for u in version_files],
        'anim_meta': _animation_meta_for_upload(request, project),
    })


def _bump_and_redirect(request, version, file_index):
    Version.objects.filter(pk=version.pk).update(downloads=F('downloads') + 1)
    return redirect(
        reverse('library:file_request', args=[file_index.uuid]) + '?download=1'
    )


@login_required
def regenerate_metadata(request, slug):
    """Rewrite the latest version's release zip so pack.mcmeta carries the
    chosen animationframework.id, then re-parse the version's metadata."""
    project = get_object_or_404(Project, slug=slug)
    if not project.can_edit(request.user):
        return HttpResponseForbidden('You do not have permission to edit this project.')
    if request.method != 'POST':
        return HttpResponse(status=405)

    latest = project.versions.first()
    if latest is None:
        messages.error(request, 'No versions to regenerate.')
        return redirect('library:project_edit', slug=slug)
    release = latest.files.filter(kind='release').first()
    if release is None:
        messages.error(request, 'No release file to regenerate.')
        return redirect('library:project_edit', slug=slug)

    ns = request.POST.get('af_namespace', '').strip()
    pid = request.POST.get('af_pack_id', '').strip()
    if not _valid_id_component(ns) or not _valid_id_component(pid):
        messages.error(request, 'Animation ID must only use letters, numbers, underscores, dots or dashes.')
        return redirect('library:project_edit', slug=slug)

    animation_id = f'{ns}:{pid}'
    create_pack = {
        'pack_format': 64,
        'supported_formats': [64, 81],
        'min_format': 64,
        'max_format': 81,
        'description': project.title,
    }
    path = _stored_path(release.file)
    if not inject_animationframework(
        path, _animation_fields(project, latest.version_name, animation_id), create_pack,
    ):
        messages.error(request, 'Could not regenerate pack metadata.')
        return redirect('library:project_edit', slug=slug)

    refresh_file_index(release.file)
    _reparse_version(latest)
    messages.success(request, f'Pack metadata regenerated with id {animation_id}.')
    return redirect('library:project_edit', slug=slug)


def version_download(request, slug, version_id):
    """Download the first release file of a version (kept for API/mods)."""
    version = get_object_or_404(
        Version.objects.select_related('project'),
        pk=version_id, project__slug=slug,
    )
    first = version.files.select_related('file').first()
    if first is None:
        raise Http404
    return _bump_and_redirect(request, version, first.file)


def version_file_download(request, slug, version_id, file_uuid):
    """Download a specific file of a version."""
    version = get_object_or_404(
        Version.objects.select_related('project'),
        pk=version_id, project__slug=slug,
    )
    vf = get_object_or_404(
        version.files.select_related('file'), file__uuid=file_uuid,
    )
    return _bump_and_redirect(request, version, vf.file)


@login_required
def asset_upload(request, slug):
    project = get_object_or_404(Project, slug=slug)
    if not project.can_edit(request.user):
        return HttpResponseForbidden('You do not have permission to add media.')

    if request.method == 'POST':
        media = TempUpload.objects.filter(user=request.user, status='pending', kind='media')
        if not media.exists():
            messages.error(request, 'No media uploaded yet.')
        else:
            count = 0
            for temp in media.select_related('file'):
                index = _adopt_temp(temp, project.pk, 'gallery', 'asset')
                ProjectAsset.objects.create(
                    project=project, file=index,
                    caption=request.POST.get(f'caption_{temp.uuid}', '')[:128],
                    uploaded_by=request.user,
                )
                temp.status = 'used'
                temp.save(update_fields=['status'])
                count += 1
            messages.success(request, f'{count} media file{"s" if count != 1 else ""} added to the gallery.')
            notify_project_change(
                project, request.user, 'asset',
                f'{request.user.username} added {count} new media file{"s" if count != 1 else ""} to {project.title}',
            )
            return redirect('library:project_detail', slug=project.slug)

    return render(request, 'library/asset_upload.html', {
        'project': project,
        'pending_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
    })


@login_required
def asset_delete(request, slug, asset_id):
    project = get_object_or_404(Project, slug=slug)
    asset = get_object_or_404(ProjectAsset, pk=asset_id, project=project)
    if not project.can_edit(request.user):
        return HttpResponseForbidden('You do not have permission to delete media.')
    if request.method != 'POST':
        return HttpResponse(status=405)

    delete_file_index(asset.file)
    asset.delete()

    accept = request.META.get('HTTP_ACCEPT', '')
    if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or 'application/json' in accept:
        return JsonResponse({'success': True})
    messages.success(request, 'Media deleted.')
    return redirect('library:project_detail', slug=project.slug)


def asset_thumbnail(request, slug, asset_id):
    """Cached ffmpeg thumbnail for a gallery video/gif (grid preview only;
    the modal loads the real file)."""
    from pathlib import Path as _Path

    from django.http import FileResponse

    from .thumbnails import generate_thumbnail

    asset = get_object_or_404(
        ProjectAsset.objects.select_related('file', 'project'),
        pk=asset_id, project__slug=slug,
    )
    if not (asset.is_video or asset.is_gif):
        raise Http404
    stored = generate_thumbnail(asset.file)
    if stored is None:
        raise Http404
    fh = open(_Path(settings.MEDIA_ROOT) / stored, 'rb')
    response = FileResponse(fh, content_type='image/jpeg')
    response['Cache-Control'] = 'public, max-age=86400'
    return response


@login_required
def contributors(request, slug):
    project = get_object_or_404(Project, slug=slug)
    if request.user != project.owner:
        return HttpResponseForbidden('Only the owner can manage contributors.')

    if request.method == 'POST':
        action = request.POST.get('action')
        if action == 'add':
            username = request.POST.get('username', '').strip()
            user = get_user_model().objects.filter(username__iexact=username).first()
            if user is None:
                messages.error(request, f'No account named "{username}".')
            elif user == project.owner:
                messages.error(request, 'The owner is already a contributor.')
            else:
                _, created = ProjectContributor.objects.get_or_create(
                    project=project, user=user, defaults={'added_by': request.user},
                )
                project.sync_creator_tags(actor=request.user)
                if created:
                    messages.success(request, f'{user.username} added as a contributor.')
                else:
                    messages.info(request, f'{user.username} is already a contributor.')
        elif action == 'remove':
            contributor = ProjectContributor.objects.filter(
                pk=request.POST.get('contributor_id'), project=project,
            ).first()
            if contributor is not None:
                user = contributor.user
                contributor.delete()
                project.detach_creator_tag(user)
                project.sync_creator_tags(actor=request.user)
                messages.success(request, f'{user.username} removed.')
        return redirect('library:contributors', slug=project.slug)

    return render(request, 'library/contributors.html', {
        'project': project,
        'contributors': project.contributors.select_related('user').all(),
        'contributor_form': ContributorForm(),
    })


# ---------- Ratings & Forum ----------

def refresh_rating_fields(project):
    """Recompute Project.rating_score (net) + rating_count from its votes."""
    agg = project.ratings.aggregate(
        up=Count('pk', filter=Q(value=True)),
        total=Count('pk'),
    )
    up = agg['up'] or 0
    total = agg['total'] or 0
    project.rating_count = total
    project.rating_score = 2 * up - total
    project.save(update_fields=['rating_score', 'rating_count'])


def _require_rating_user(request, project):
    """Redirect guests to login; return None when allowed."""
    if request.user.is_authenticated:
        return None
    return redirect('{}?next={}'.format(
        reverse('profiles:login'),
        request.path,
    ))


def rate_project(request, slug):
    """Set / change / clear the current user's vote on a project."""
    project = get_object_or_404(Project, slug=slug)
    if (login_redirect := _require_rating_user(request, project)) is not None:
        return login_redirect
    if request.method != 'POST':
        return redirect('library:project_detail', slug=slug)

    value_raw = request.POST.get('value', '').strip()
    if value_raw in ('1', 'up'):
        value = True
    elif value_raw in ('0', 'down'):
        value = False
    else:
        value = None  # clear the vote

    rating, created = Rating.objects.get_or_create(
        project=project, user=request.user, defaults={'value': value if value is not None else True},
    )
    if value is None:
        rating.delete()
        messages.success(request, 'Your rating was removed.')
    elif created or rating.value != value:
        rating.value = value
        rating.save(update_fields=['value', 'updated_at'])
        messages.success(request, 'Your rating was updated.')
    else:
        messages.info(request, 'Your rating is unchanged.')
    refresh_rating_fields(project)
    return redirect(reverse('library:project_detail', args=[slug]) + '#rating')


def add_comment(request, slug):
    project = get_object_or_404(Project, slug=slug)
    if (login_redirect := _require_rating_user(request, project)) is not None:
        return login_redirect
    if request.method == 'POST':
        body = request.POST.get('body', '').strip()
        if body:
            Comment.objects.create(project=project, user=request.user, body=body)
            notify_project_change(
                project, request.user, 'comment',
                f'{request.user.username} commented on {project.title}',
            )
            messages.success(request, 'Comment added.')
        else:
            messages.error(request, 'Comment cannot be empty.')
    return redirect(reverse('library:project_detail', args=[slug]) + '#forum')


def edit_comment(request, slug, comment_id):
    project = get_object_or_404(Project, slug=slug)
    if (login_redirect := _require_rating_user(request, project)) is not None:
        return login_redirect
    comment = get_object_or_404(Comment, pk=comment_id, project=project)
    if comment.user_id != request.user.pk and not request.user.is_staff:
        return HttpResponseForbidden('You can only edit your own comments.')
    if request.method == 'POST':
        body = request.POST.get('body', '').strip()
        if body:
            comment.body = body
            comment.save(update_fields=['body', 'updated_at'])
            messages.success(request, 'Comment updated.')
        else:
            messages.error(request, 'Comment cannot be empty.')
    return redirect(reverse('library:project_detail', args=[slug]) + '#forum')


def delete_comment(request, slug, comment_id):
    project = get_object_or_404(Project, slug=slug)
    if (login_redirect := _require_rating_user(request, project)) is not None:
        return login_redirect
    comment = get_object_or_404(Comment, pk=comment_id, project=project)
    if comment.user_id != request.user.pk and not request.user.is_staff:
        return HttpResponseForbidden('You can only delete your own comments.')
    if request.method == 'POST':
        comment.delete()
        messages.success(request, 'Comment deleted.')
    return redirect(reverse('library:project_detail', args=[slug]) + '#forum')


# ---------- API (autocomplete) ----------

def api_tags_autocomplete(request):
    q = request.GET.get('q', '').strip()
    tags = Tag.objects.select_related('category').order_by('name')
    if q:
        tags = tags.filter(Q(name__icontains=q) | Q(category__name__icontains=q))
    data = [
        {'name': t.name, 'category': t.category.slug, 'category_name': t.category.name, 'color': t.category.color}
        for t in tags[:20]
    ]
    return JsonResponse(data, safe=False)


def api_users_autocomplete(request):
    q = request.GET.get('q', '').strip()
    users = get_user_model().objects.filter(username__icontains=q).order_by('username')[:20]
    return JsonResponse([{'username': u.username} for u in users], safe=False)


# ---------- API (draft uploads + autosave) ----------

MEDIA_PREFIXES = ('image/', 'video/')


@login_required
def api_upload_temp(request):
    """Accept one file (J621-style: the JS uploads each file individually)."""
    if request.method != 'POST':
        return JsonResponse({'error': 'Method not allowed'}, status=405)
    uploaded = request.FILES.get('file')
    if uploaded is None:
        return JsonResponse({'error': 'No file'}, status=400)

    kind = request.POST.get('kind', 'media')
    if kind not in dict(TempUpload.KIND_CHOICES):
        kind = 'media'

    content_type = getattr(uploaded, 'content_type', '') or ''
    if kind in ('thumbnail', 'media'):
        if not content_type.startswith(MEDIA_PREFIXES):
            return JsonResponse({'error': 'Only images, videos and gifs are allowed here.'}, status=400)
    if kind == 'thumbnail' and not content_type.startswith('image/'):
        return JsonResponse({'error': 'The thumbnail must be an image.'}, status=400)

    draft_uuid_raw = request.POST.get('draft_uuid', '').strip()
    draft_uuid = draft_uuid_raw or None
    if draft_uuid is not None:
        try:
            draft_uuid = UUID(draft_uuid_raw)
        except (ValueError, TypeError):
            draft_uuid = None

    # Thumbnail is a single slot: discard any earlier pending one for this
    # draft (or non-draft flow). Version and media accept multiple files.
    if kind == 'thumbnail':
        old_qs = TempUpload.objects.filter(user=request.user, status='pending', kind='thumbnail')
        if draft_uuid is not None:
            old_qs = old_qs.filter(draft_uuid=draft_uuid)
        else:
            old_qs = old_qs.filter(draft_uuid__isnull=True)
        for old in old_qs:
            delete_file_index(old.file)
            old.delete()

    index = store_temp_file(request.user, kind, uploaded)
    upload = TempUpload.objects.create(
        user=request.user, kind=kind, file=index, draft_uuid=draft_uuid,
    )
    return JsonResponse(_serialize_temp(upload))


@login_required
def api_delete_temp(request, upload_uuid):
    upload = get_object_or_404(TempUpload, uuid=upload_uuid, user=request.user)
    if request.method != 'POST':
        return JsonResponse({'error': 'Method not allowed'}, status=405)
    if upload.status == 'pending':
        delete_file_index(upload.file)
        upload.delete()
    return JsonResponse({'success': True})


@login_required
def api_draft(request):
    """Autosave endpoint for a single draft, keyed by its UUID."""
    def _draft_for(uuid_raw):
        try:
            return ProjectDraft.objects.filter(uuid=UUID(uuid_raw), user=request.user).first()
        except (ValueError, TypeError):
            return None

    if request.method == 'GET':
        draft = _draft_for(request.GET.get('draft', ''))
        if draft is None:
            return JsonResponse({'error': 'Draft not found'}, status=404)
        return JsonResponse({
            'data': draft.data,
            'uploads': [_serialize_temp(u) for u in _pending_uploads(request.user, draft.uuid)],
        })
    if request.method == 'POST':
        try:
            payload = json.loads(request.body or b'{}')
        except json.JSONDecodeError:
            payload = {}
        draft = _draft_for(payload.get('draft', ''))
        if draft is None:
            return JsonResponse({'error': 'Draft not found'}, status=404)
        data = payload.get('data') or {}
        clean = {k: str(data.get(k, '')) for k in DRAFT_KEYS if k in data}
        draft.data = clean
        draft.save()
        return JsonResponse({
            'saved': True,
            'updated_at': draft.updated_at.isoformat(),
        })
    if request.method == 'DELETE':
        draft = _draft_for(request.GET.get('draft', ''))
        if draft is None:
            return JsonResponse({'error': 'Draft not found'}, status=404)
        for temp in TempUpload.objects.filter(draft_uuid=draft.uuid):
            delete_file_index(temp.file)
        TempUpload.objects.filter(draft_uuid=draft.uuid).delete()
        draft.delete()
        return JsonResponse({'success': True})
    return JsonResponse({'error': 'Method not allowed'}, status=405)
