import io
import json
import os
import secrets
import shutil
import tempfile
import unittest
import zipfile

from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse

from profiles.models import ApiToken, UserProfile

from .models import (
    Project,
    ProjectAsset,
    ProjectContributor,
    ProjectDraft,
    Tag,
    TagCategory,
    TagList,
    TempUpload,
    Version,
    VersionFile,
)


class UGCMediaTestCase(TestCase):
    """Base class that redirects uploaded media to a temp directory."""

    @classmethod
    def setUpClass(cls):
        cls._media_root = tempfile.mkdtemp(prefix='packs_ugc_test_media_')
        cls.override = override_settings(MEDIA_ROOT=cls._media_root)
        cls.override.enable()
        super().setUpClass()

    @classmethod
    def tearDownClass(cls):
        super().tearDownClass()
        cls.override.disable()
        shutil.rmtree(cls._media_root, ignore_errors=True)


class UGCGatedTestCase(TestCase):
    """Base class with a helper to authorize the client past the gate."""

    def gate(self):
        session = self.client.session
        session['authorized'] = True
        session.save()


def make_png_bytes(size=(8, 8), color='red'):
    import PIL.Image

    buf = io.BytesIO()
    PIL.Image.new('RGB', size, color).save(buf, format='PNG')
    return buf.getvalue()


def make_zip_bytes():
    import zipfile

    buf = io.BytesIO()
    with zipfile.ZipFile(buf, 'w') as zf:
        zf.writestr('pack.mcmeta', '{"pack": {"pack_format": 15}}')
    return buf.getvalue()


class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)
        self.bob = User.objects.create_user(username='Bob', password='pw')
        UserProfile.objects.get_or_create(user=self.bob)

    def _upload_temp(self, kind, name, content, content_type, draft_uuid=None):
        data = {'kind': kind, 'file': SimpleUploadedFile(name, content, content_type=content_type)}
        if draft_uuid is not None:
            data['draft_uuid'] = str(draft_uuid)
        return self.client.post(
            reverse('library:api_upload_temp'), data,
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )

    def _create_project(self, user, title='Test Pack', tags='general:furry content:threesome'):
        self.gate()
        self.client.login(username=user.username, password='pw')
        uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1]
        self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png', draft_uuid=uuid)
        self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid)
        data = {
            'title': title,
            'summary': 'A test pack.',
            'category': 'mod',
            'description': '# Hello\n\nWorld.',
            'tags': tags,
            'version_name': '1.0.0',
            'changelog': 'First release.',
        }
        return self.client.post(reverse('library:project_create', args=[uuid]), data)

    def test_create_requires_login(self):
        self.gate()
        resp = self.client.get(reverse('library:project_create_new'))
        self.assertEqual(resp.status_code, 302)

    def test_create_project(self):
        self.gate()
        resp = self._create_project(self.alice)
        self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
        project = Project.objects.get(slug='test-pack')
        self.assertEqual(project.owner, self.alice)
        self.assertEqual(project.category, 'mod')
        # First version + thumbnail stored and adopted (moved under project_<pk>/).
        self.assertEqual(project.versions.count(), 1)
        self.assertIsNotNone(project.thumbnail)
        self.assertTrue(project.thumbnail.stored_path.startswith('project_'))
        self.assertTrue(project.versions.first().files.first().file.stored_path.startswith('project_'))
        # Temp uploads marked used.
        self.assertEqual(
            TempUpload.objects.filter(user=self.alice, status='used').count(), 2
        )
        # Tags auto-created and linked.
        names = set(TagList.objects.filter(project=project).values_list('tag__name', flat=True))
        self.assertIn('furry', names)
        self.assertIn('threesome', names)
        self.assertIn('alice', names)  # creator tag auto-synced
        # Creator tag exists with category creator.
        creator_cat = TagCategory.objects.get(slug='creator')
        self.assertTrue(Tag.objects.filter(name='alice', category=creator_cat).exists())

    def test_create_links_auto_tags(self):
        """Auto tags (from version metadata) must be linked to the project on
        creation, not just created as orphan Tag rows."""
        self.gate()
        self.client.login(username='Alice', password='pw')
        uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1]
        self._upload_temp(
            'version', 'testmod.jar',
            _zip_bytes({'META-INF/mods.toml': 'modLoader="javafml"\nloaderVersion="[1,)"\n[[mods]]\nmodId="testmod"\nversion="1.0.0"\ndisplayName="Test Mod"\n'}),
            'application/java-archive',
            draft_uuid=uuid,
        )
        self.client.post(reverse('library:project_create', args=[uuid]), {
            'title': 'Auto Tag Mod', 'category': 'mod', 'version_name': '1.0.0',
        })
        project = Project.objects.get(slug='auto-tag-mod')
        loader_links = TagList.objects.filter(
            project=project, tag__category__slug='loader',
        )
        self.assertTrue(loader_links.exists(), 'auto loader tag should be linked to the project')

    def test_create_requires_version_file(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1]
        resp = self.client.post(reverse('library:project_create', args=[uuid]), {
            'title': 'No File',
            'category': 'mod',
            'version_name': '1.0.0',
        })
        self.assertEqual(resp.status_code, 200)
        self.assertFalse(Project.objects.filter(slug='no-file').exists())

    def test_create_adopts_media_and_captions(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1]
        self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png', draft_uuid=uuid)
        self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid)
        self._upload_temp('media', 'pic1.png', make_png_bytes(), 'image/png', draft_uuid=uuid)
        self._upload_temp('media', 'pic2.png', make_png_bytes(), 'image/png', draft_uuid=uuid)
        media = TempUpload.objects.filter(kind='media', draft_uuid=uuid).order_by('created_at')
        caption_payload = {
            f'caption_{media[0].uuid}': 'Showcase',
            f'caption_{media[1].uuid}': 'Second angle',
        }
        resp = self.client.post(reverse('library:project_create', args=[uuid]), {
            'title': 'Media Pack',
            'category': 'skin',
            'version_name': '1.0.0',
            **caption_payload,
        })
        self.assertRedirects(resp, reverse('library:project_detail', args=['media-pack']))
        project = Project.objects.get(slug='media-pack')
        self.assertEqual(project.assets.count(), 2)
        # Per-file captions stored individually.
        captions = dict(project.assets.values_list('file__original_filename', 'caption'))
        self.assertEqual(captions['pic1.png'], 'Showcase')
        self.assertEqual(captions['pic2.png'], 'Second angle')
        for asset in project.assets.all():
            self.assertTrue(asset.file.stored_path.startswith('project_'))

    def test_detail_requires_gate(self):
        self._create_project(self.alice)
        self.client.logout()
        resp = self.client.get(reverse('library:project_detail', args=['test-pack']))
        self.assertEqual(resp.status_code, 302)  # gate redirects HTML pages

    def test_detail_gated_ok(self):
        self._create_project(self.alice)
        self.client.logout()
        self.gate()
        resp = self.client.get(reverse('library:project_detail', args=['test-pack']))
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, 'Test Pack')
        self.assertContains(resp, '<h1>Hello</h1>', html=True)

    def test_detail_unknown_404(self):
        self.gate()
        resp = self.client.get(reverse('library:project_detail', args=['nope']))
        self.assertEqual(resp.status_code, 404)


class UGCProjectDraftTests(UGCMediaTestCase, UGCGatedTestCase):
    """Multiple per-user drafts, keyed by UUID, with upload scoping."""

    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)
        self.bob = User.objects.create_user(username='Bob', password='pw')
        UserProfile.objects.get_or_create(user=self.bob)

    def _auth(self, user):
        self.client.force_login(user)
        self.gate()

    def _upload_temp(self, kind, name, content, content_type, draft_uuid=None):
        data = {'kind': kind, 'file': SimpleUploadedFile(name, content, content_type=content_type)}
        if draft_uuid is not None:
            data['draft_uuid'] = str(draft_uuid)
        return self.client.post(
            reverse('library:api_upload_temp'), data,
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )

    def _new_draft(self, user=None):
        self._auth(user or self.alice)
        resp = self.client.get(reverse('library:project_create_new'))
        uuid = resp.url.rstrip('/').split('/')[-1]
        return uuid, resp

    def test_new_project_creates_draft_and_redirects(self):
        uuid, resp = self._new_draft()
        self.assertRedirects(
            resp, reverse('library:project_create', args=[uuid]),
            fetch_redirect_response=False,
        )
        self.assertTrue(ProjectDraft.objects.filter(uuid=uuid, user=self.alice).exists())

    def test_my_projects_lists_projects_and_drafts(self):
        project = Project.objects.create(
            slug='mine', title='Mine', category='mod', owner=self.alice,
        )
        uuid, _ = self._new_draft()
        self._auth(self.alice)
        resp = self.client.get(reverse('library:my_projects'))
        self.assertContains(resp, 'Mine')
        self.assertContains(resp, 'Untitled draft')
        self.assertEqual(Project.objects.get(pk=project.pk).title, 'Mine')

    def test_wrong_user_cannot_access_draft(self):
        uuid, _ = self._new_draft()
        self._auth(self.bob)
        resp = self.client.get(reverse('library:project_create', args=[uuid]))
        self.assertEqual(resp.status_code, 302)
        self.assertEqual(resp.url, reverse('library:my_projects'))
        # API save also refused.
        resp = self.client.post(
            reverse('library:api_draft'),
            data=json.dumps({'draft': uuid, 'data': {'title': 'hack'}}),
            content_type='application/json',
        )
        self.assertEqual(resp.status_code, 404)
        draft = ProjectDraft.objects.get(uuid=uuid)
        self.assertNotEqual(draft.data.get('title'), 'hack')

    def test_autosave_scopes_to_its_draft(self):
        uuid_a, _ = self._new_draft()
        uuid_b, _ = self._new_draft()
        self._auth(self.alice)
        self.client.post(
            reverse('library:api_draft'),
            data=json.dumps({'draft': uuid_a, 'data': {'title': 'Draft A'}}),
            content_type='application/json',
        )
        self.assertEqual(ProjectDraft.objects.get(uuid=uuid_a).data.get('title'), 'Draft A')
        self.assertEqual(ProjectDraft.objects.get(uuid=uuid_b).data.get('title'), None)

    def test_uploads_scoped_per_draft(self):
        uuid_a, _ = self._new_draft()
        uuid_b, _ = self._new_draft()
        self._auth(self.alice)
        self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid_a)
        self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_a).count(), 1)
        self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_b).count(), 0)
        self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_a, status='pending').count(), 1)
        self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_b, status='pending').count(), 0)

    def test_submit_consumes_draft_uploads_and_deletes_draft(self):
        uuid, _ = self._new_draft()
        self._auth(self.alice)
        self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid)
        resp = self.client.post(
            reverse('library:project_create', args=[uuid]),
            {
                'title': 'From Draft', 'summary': 's', 'description': 'd',
                'category': 'mod', 'tags': 'general:test',
                'version_name': '1.0.0', 'changelog': 'c',
            },
        )
        self.assertRedirects(resp, reverse('library:project_detail', args=['from-draft']))
        self.assertTrue(Project.objects.filter(title='From Draft', owner=self.alice).exists())
        self.assertFalse(ProjectDraft.objects.filter(uuid=uuid).exists())
        self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid, status='used').count(), 1)

    def test_draft_delete_purges_uploads(self):
        uuid, _ = self._new_draft()
        self._auth(self.alice)
        self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid)
        resp = self.client.post(reverse('library:project_draft_delete', args=[uuid]))
        self.assertEqual(resp.status_code, 302)
        self.assertFalse(ProjectDraft.objects.filter(uuid=uuid).exists())
        self.assertFalse(TempUpload.objects.filter(draft_uuid=uuid).exists())

    def test_skin_individual_png_builds_manifest(self):
        uuid, _ = self._new_draft()
        self._auth(self.alice)
        self._upload_temp('version', 'skin.png', make_png_bytes(size=(64, 64)), 'image/png', draft_uuid=uuid)
        self.client.post(reverse('library:project_create', args=[uuid]), {
            'title': 'My Skin', 'category': 'skin', 'version_name': '1.0.0',
        })
        version = Project.objects.get(slug='my-skin').versions.first()
        manifest = version.skins_manifest
        self.assertIn('skins', manifest)
        self.assertEqual(manifest['skins'][0]['width'], 64)
        self.assertEqual(manifest['skins'][0]['height'], 64)
        self.assertTrue(manifest['skins'][0]['url'].startswith('/api/files/'))

    def test_skin_rejects_non_skin_sized_png(self):
        uuid, _ = self._new_draft()
        self._auth(self.alice)
        self._upload_temp('version', 'small.png', make_png_bytes(size=(8, 8)), 'image/png', draft_uuid=uuid)
        self.client.post(reverse('library:project_create', args=[uuid]), {
            'title': 'Bad Skin', 'category': 'skin', 'version_name': '1.0.0',
        })
        version = Project.objects.get(slug='bad-skin').versions.first()
        self.assertEqual(version.skins_manifest, {})


class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)
        self.bob = User.objects.create_user(username='Bob', password='pw')
        UserProfile.objects.get_or_create(user=self.bob)
        self.project = Project.objects.create(
            slug='test-pack', title='Test Pack', category='mod', owner=self.alice,
        )
        self.project.sync_creator_tags()

    def test_upload_version_owner(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'version', 'file': SimpleUploadedFile('pack2.zip', make_zip_bytes(), content_type='application/zip')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        resp = self.client.post(
            reverse('library:version_upload', args=['test-pack']),
            {'version_name': '2.0.0', 'changelog': 'More stuff.'},
        )
        self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
        self.assertEqual(self.project.versions.count(), 1)
        version = self.project.versions.first()
        self.assertEqual(version.version_name, '2.0.0')
        self.assertEqual(version.files.count(), 1)
        # Adopted file moved under project_<pk>/versions/ and temp marked used.
        self.assertTrue(version.files.first().file.stored_path.startswith('project_'))
        self.assertTrue(
            TempUpload.objects.filter(user=self.alice, kind='version', status='used').exists()
        )

    def test_upload_version_multiple_files(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        for name in ('skin1.png', 'skin2.png'):
            self.client.post(
                reverse('library:api_upload_temp'),
                {'kind': 'version', 'file': SimpleUploadedFile(name, make_png_bytes(), content_type='image/png')},
                HTTP_X_REQUESTED_WITH='XMLHttpRequest',
            )
        resp = self.client.post(
            reverse('library:version_upload', args=['test-pack']),
            {'version_name': '3.0.0'},
        )
        self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
        version = self.project.versions.first()
        self.assertEqual(version.version_name, '3.0.0')
        # One version, several files, PNGs classified as png kind.
        self.assertEqual(version.files.count(), 2)
        self.assertEqual(
            set(version.files.values_list('kind', flat=True)), {'png'}
        )
        self.assertEqual(
            set(version.files.values_list('file__original_filename', flat=True)),
            {'skin1.png', 'skin2.png'},
        )

    def test_upload_version_requires_pending_file(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:version_upload', args=['test-pack']),
            {'version_name': '2.0.0'},
        )
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(self.project.versions.count(), 0)

    def test_upload_version_non_contributor_denied(self):
        self.gate()
        self.client.login(username='Bob', password='pw')
        resp = self.client.post(
            reverse('library:version_upload', args=['test-pack']),
            {'version_name': '2.0.0'},
        )
        self.assertEqual(resp.status_code, 403)
        self.assertEqual(self.project.versions.count(), 0)

    def test_contributor_can_upload(self):
        ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
        self.project.sync_creator_tags()
        self.gate()
        self.client.login(username='Bob', password='pw')
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'version', 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        resp = self.client.post(
            reverse('library:version_upload', args=['test-pack']),
            {'version_name': '2.0.0'},
        )
        self.assertEqual(resp.status_code, 302)
        self.assertEqual(self.project.versions.count(), 1)

    def test_download_bumps_counter_and_serves(self):
        from library.storage import store_file

        index = store_file(
            self.alice, 'version',
            SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
            self.project.pk, subdir='versions',
        )
        version = Version.objects.create(project=self.project, version_name='1.0.0')
        VersionFile.objects.create(version=version, file=index, kind='release')
        self.gate()
        resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk]))
        self.assertEqual(resp.status_code, 302)
        version.refresh_from_db()
        self.assertEqual(version.downloads, 1)
        # Follow redirect → file served.
        resp = self.client.get(resp.url)
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(b''.join(resp.streaming_content), make_zip_bytes())

    def test_per_file_download(self):
        from library.storage import store_file

        index = store_file(
            self.alice, 'version',
            SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
            self.project.pk, subdir='versions',
        )
        version = Version.objects.create(project=self.project, version_name='1.0.0')
        vf = VersionFile.objects.create(version=version, file=index, kind='release')
        self.gate()
        resp = self.client.get(
            reverse('library:version_file_download', args=['test-pack', version.pk, index.uuid])
        )
        self.assertEqual(resp.status_code, 302)
        version.refresh_from_db()
        self.assertEqual(version.downloads, 1)
        self.assertEqual(resp.url, reverse('library:file_request', args=[index.uuid]) + '?download=1')
        # The parent version_download route still works (first file).
        resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk]))
        self.assertEqual(resp.status_code, 302)
        self.assertTrue(vf)


class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)
        self.bob = User.objects.create_user(username='Bob', password='pw')
        UserProfile.objects.get_or_create(user=self.bob)
        self.project = Project.objects.create(
            slug='test-pack', title='Test Pack', category='skin', owner=self.alice,
        )

    def test_upload_image_asset_with_caption(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'media', 'file': SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        temp = TempUpload.objects.get(kind='media')
        resp = self.client.post(
            reverse('library:asset_upload', args=['test-pack']),
            {f'caption_{temp.uuid}': 'Preview'},
        )
        self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
        asset = ProjectAsset.objects.get()
        self.assertEqual(asset.caption, 'Preview')
        self.assertEqual(asset.uploaded_by, self.alice)
        self.assertFalse(asset.is_video)

    def test_asset_captions_are_per_file(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(color='red'), content_type='image/png')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'media', 'file': SimpleUploadedFile('b.png', make_png_bytes(color='blue'), content_type='image/png')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        temps = list(TempUpload.objects.filter(kind='media').order_by('created_at'))
        self.client.post(reverse('library:asset_upload', args=['test-pack']), {
            f'caption_{temps[0].uuid}': 'First',
            f'caption_{temps[1].uuid}': 'Second',
        })
        captions = dict(ProjectAsset.objects.values_list('file__original_filename', 'caption'))
        self.assertEqual(captions, {'a.png': 'First', 'b.png': 'Second'})

    def test_reject_non_media_file(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'media', 'file': SimpleUploadedFile('evil.zip', make_zip_bytes(), content_type='application/zip')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.assertEqual(resp.status_code, 400)
        self.assertEqual(ProjectAsset.objects.count(), 0)

    def test_gallery_upload_requires_pending_media(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(reverse('library:asset_upload', args=['test-pack']))
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(ProjectAsset.objects.count(), 0)

    def test_asset_delete_removes_index(self):
        from library.storage import store_file

        index = store_file(
            self.alice, 'asset',
            SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
            self.project.pk, subdir='gallery',
        )
        asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(reverse('library:asset_delete', args=['test-pack', asset.pk]))
        self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
        self.assertEqual(ProjectAsset.objects.count(), 0)
        self.assertFalse(Project.objects.filter(pk=self.project.pk).exists() is False)  # project intact
        self.assertTrue(Project.objects.filter(pk=self.project.pk).exists())

    def test_asset_delete_ajax_returns_json(self):
        from library.storage import store_file

        index = store_file(
            self.alice, 'asset',
            SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
            self.project.pk, subdir='gallery',
        )
        asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:asset_delete', args=['test-pack', asset.pk]),
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.assertEqual(resp.status_code, 200)
        self.assertJSONEqual(resp.content, {'success': True})
        self.assertEqual(ProjectAsset.objects.count(), 0)

    def test_asset_delete_requires_post(self):
        from library.storage import store_file

        index = store_file(
            self.alice, 'asset',
            SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
            self.project.pk, subdir='gallery',
        )
        asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.get(reverse('library:asset_delete', args=['test-pack', asset.pk]))
        self.assertEqual(resp.status_code, 405)
        self.assertEqual(ProjectAsset.objects.count(), 1)

    def test_asset_delete_non_contributor_denied(self):
        from library.storage import store_file

        index = store_file(
            self.alice, 'asset',
            SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
            self.project.pk, subdir='gallery',
        )
        asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
        self.gate()
        self.client.login(username='Bob', password='pw')
        resp = self.client.post(reverse('library:asset_delete', args=['test-pack', asset.pk]))
        self.assertEqual(resp.status_code, 403)
        self.assertEqual(ProjectAsset.objects.count(), 1)


class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)

    def _post_json(self, url, payload):
        import json

        return self.client.post(url, json.dumps(payload), content_type='application/json')

    def test_upload_api_requires_auth(self):
        resp = self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.assertEqual(resp.status_code, 401)

    def test_temp_upload_media_accepts_video(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'media', 'file': SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.assertEqual(resp.status_code, 200)
        self.assertTrue(resp.json()['uuid'])
        upload = TempUpload.objects.get()
        self.assertEqual(upload.kind, 'media')
        self.assertEqual(upload.status, 'pending')
        self.assertTrue(upload.file.stored_path.startswith('uploads/user_'))

    def test_temp_upload_thumbnail_rejects_video(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'thumbnail', 'file': SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.assertEqual(resp.status_code, 400)
        self.assertEqual(TempUpload.objects.count(), 0)

    def test_thumbnail_single_slot_version_multi(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'thumbnail', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'thumbnail', 'file': SimpleUploadedFile('b.png', make_png_bytes(color='blue'), content_type='image/png')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'version', 'file': SimpleUploadedFile('v1.zip', make_zip_bytes(), content_type='application/zip')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'version', 'file': SimpleUploadedFile('v2.zip', make_zip_bytes(), content_type='application/zip')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        # Thumbnail is a single slot; version accepts multiple files.
        self.assertEqual(
            TempUpload.objects.filter(kind='thumbnail', status='pending').count(), 1
        )
        self.assertEqual(
            TempUpload.objects.filter(kind='version', status='pending').count(), 2
        )

    def test_temp_upload_delete(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        upload = TempUpload.objects.get()
        resp = self.client.post(
            reverse('library:api_delete_temp', args=[upload.uuid]), HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(TempUpload.objects.count(), 0)

    def test_delete_others_upload_forbidden(self):
        User = get_user_model()
        bob = User.objects.create_user(username='Bob', password='pw')
        UserProfile.objects.get_or_create(user=bob)
        self.gate()
        self.client.login(username='Alice', password='pw')
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        upload = TempUpload.objects.get()
        self.client.logout()
        self.gate()
        self.client.login(username='Bob', password='pw')
        resp = self.client.post(
            reverse('library:api_delete_temp', args=[upload.uuid]), HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.assertEqual(resp.status_code, 404)
        self.assertEqual(TempUpload.objects.count(), 1)

    def test_draft_save_and_restore(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1]
        self._post_json(reverse('library:api_draft'), {'draft': uuid, 'data': {
            'title': 'Draft Title',
            'summary': 'Draft summary',
            'category': 'mod',
            'description': 'Hello draft',
            'tags': 'species:dragon',
            'version_name': '2.0.0',
        }})
        draft = ProjectDraft.objects.get(uuid=uuid, user=self.alice)
        self.assertEqual(draft.data['title'], 'Draft Title')

        resp = self.client.get(reverse('library:api_draft'), {'draft': uuid})
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(resp.json()['data']['title'], 'Draft Title')

    def test_create_form_prefills_draft(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1]
        self._post_json(reverse('library:api_draft'), {'draft': uuid, 'data': {'title': 'My Draft Pack'}})
        resp = self.client.get(reverse('library:project_create', args=[uuid]))
        self.assertContains(resp, 'value="My Draft Pack"')

    def test_create_clears_draft(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1]
        self._post_json(reverse('library:api_draft'), {'draft': uuid, 'data': {'title': 'My Draft Pack'}})
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'version', 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'), 'draft_uuid': uuid},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        resp = self.client.post(reverse('library:project_create', args=[uuid]), {
            'title': 'My Draft Pack', 'category': 'mod', 'version_name': '1.0.0',
        })
        self.assertRedirects(resp, reverse('library:project_detail', args=['my-draft-pack']))
        self.assertFalse(ProjectDraft.objects.filter(uuid=uuid).exists())


class UGCPermissionTests(UGCMediaTestCase, UGCGatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)
        self.bob = User.objects.create_user(username='Bob', password='pw')
        UserProfile.objects.get_or_create(user=self.bob)
        self.carol = User.objects.create_user(username='Carol', password='pw')
        UserProfile.objects.get_or_create(user=self.carol)
        self.project = Project.objects.create(
            slug='test-pack', title='Test Pack', category='mod', owner=self.alice,
        )

    def test_edit_owner_ok(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:project_edit', args=['test-pack']),
            {
                'title': 'Renamed Pack',
                'summary': 'New summary',
                'description': 'Updated.',
                'category': 'mod',
            },
        )
        self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
        self.project.refresh_from_db()
        self.assertEqual(self.project.title, 'Renamed Pack')

    def test_edit_replaces_thumbnail_via_temp(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'thumbnail', 'file': SimpleUploadedFile('thumb.png', make_png_bytes(), content_type='image/png')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        resp = self.client.post(
            reverse('library:project_edit', args=['test-pack']),
            {'title': 'Test Pack', 'category': 'mod', 'description': 'x'},
        )
        self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
        self.project.refresh_from_db()
        self.assertIsNotNone(self.project.thumbnail)
        self.assertTrue(self.project.thumbnail.stored_path.startswith('project_'))
        self.assertTrue(
            TempUpload.objects.filter(user=self.alice, kind='thumbnail', status='used').exists()
        )

    def test_edit_shows_existing_thumbnail_preview(self):
        from library.storage import store_file

        index = store_file(
            self.alice, 'thumbnail',
            SimpleUploadedFile('thumb.png', make_png_bytes(), content_type='image/png'),
            self.project.pk, subdir='',
        )
        self.project.thumbnail = index
        self.project.save()
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.get(reverse('library:project_edit', args=['test-pack']))
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, str(index.uuid))

    def test_edit_non_contributor_denied(self):
        self.gate()
        self.client.login(username='Bob', password='pw')
        resp = self.client.post(
            reverse('library:project_edit', args=['test-pack']),
            {'title': 'Hijacked', 'category': 'mod'},
        )
        self.assertEqual(resp.status_code, 403)
        self.project.refresh_from_db()
        self.assertEqual(self.project.title, 'Test Pack')

    def test_edit_contributor_ok(self):
        ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
        self.gate()
        self.client.login(username='Bob', password='pw')
        resp = self.client.post(
            reverse('library:project_edit', args=['test-pack']),
            {
                'title': 'Test Pack',
                'summary': 'Edits by contributor.',
                'description': 'Changed.',
                'category': 'mod',
            },
        )
        self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
        self.project.refresh_from_db()
        self.assertEqual(self.project.summary, 'Edits by contributor.')

    def test_delete_owner_only(self):
        self.gate()
        self.client.login(username='Bob', password='pw')
        resp = self.client.post(reverse('library:project_delete', args=['test-pack']))
        self.assertEqual(resp.status_code, 403)
        self.assertTrue(Project.objects.filter(pk=self.project.pk).exists())

        self.client.logout()
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(reverse('library:project_delete', args=['test-pack']))
        self.assertRedirects(resp, reverse('library:browse'))
        self.assertFalse(Project.objects.filter(pk=self.project.pk).exists())

    def test_contributor_management_owner_only(self):
        self.gate()
        self.client.login(username='Bob', password='pw')
        resp = self.client.post(
            reverse('library:contributors', args=['test-pack']),
            {'action': 'add', 'username': 'Carol'},
        )
        self.assertEqual(resp.status_code, 403)
        self.assertEqual(ProjectContributor.objects.count(), 0)

        self.client.logout()
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:contributors', args=['test-pack']),
            {'action': 'add', 'username': 'Carol'},
        )
        self.assertRedirects(resp, reverse('library:contributors', args=['test-pack']))
        self.assertTrue(
            ProjectContributor.objects.filter(project=self.project, user=self.carol).exists()
        )

    def test_contributor_creator_tag_sync_and_detach(self):
        ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
        self.project.sync_creator_tags()
        creator_cat = TagCategory.objects.get(slug='creator')
        self.assertTrue(Tag.objects.filter(name='alice', category=creator_cat).exists())
        self.assertTrue(Tag.objects.filter(name='bob', category=creator_cat).exists())
        self.assertEqual(
            TagList.objects.filter(project=self.project, tag__category=creator_cat).count(), 2
        )
        # Remove Bob as a contributor → his creator tag detaches.
        ProjectContributor.objects.filter(project=self.project, user=self.bob).delete()
        self.project.detach_creator_tag(self.bob)
        self.project.sync_creator_tags(actor=self.alice)
        self.assertEqual(
            TagList.objects.filter(project=self.project, tag__category=creator_cat).count(), 1
        )
        self.assertFalse(
            TagList.objects.filter(project=self.project, tag__category=creator_cat, tag__name='bob').exists()
        )

    def test_edit_syncs_creator_tags(self):
        ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
        self.gate()
        self.client.login(username='Alice', password='pw')
        self.client.post(
            reverse('library:project_edit', args=['test-pack']),
            {
                'title': 'Test Pack',
                'description': 'x',
                'category': 'mod',
                'tags': 'species:dragon',
            },
        )
        creator_cat = TagCategory.objects.get(slug='creator')
        names = set(TagList.objects.filter(project=self.project, tag__category=creator_cat).values_list('tag__name', flat=True))
        self.assertIn('alice', names)
        self.assertIn('bob', names)
        # Manual species tag applied.
        self.assertTrue(TagList.objects.filter(project=self.project, tag__name='dragon').exists())

    def _add_manual_creator_tag(self, name='lizo'):
        creator_cat = TagCategory.objects.get(slug='creator')
        tag, _ = Tag.objects.get_or_create(name=name, category=creator_cat, created_by=self.alice)
        TagList.objects.create(project=self.project, tag=tag, added_by=self.alice)
        return tag

    def test_edit_shows_manual_creator_tag(self):
        self.project.sync_creator_tags()
        self._add_manual_creator_tag('lizo')
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.get(reverse('library:project_edit', args=['test-pack']))
        # The manual creator tag (no matching account) is serialized into the editor.
        self.assertContains(resp, 'creator:lizo')

    def test_edit_hides_account_backed_creator_tags(self):
        ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
        self.project.sync_creator_tags()
        self._add_manual_creator_tag('lizo')
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.get(reverse('library:project_edit', args=['test-pack']))
        # Manual tag visible; account-backed creator tags (alice, bob) hidden.
        self.assertContains(resp, 'creator:lizo')
        self.assertNotContains(resp, 'creator:alice')
        self.assertNotContains(resp, 'creator:bob')

    def test_edit_removing_manual_creator_tag_deletes_it(self):
        self.project.sync_creator_tags()
        self._add_manual_creator_tag('lizo')
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:project_edit', args=['test-pack']),
            {'title': 'Test Pack', 'category': 'mod', 'description': 'x', 'tags': ''},
        )
        self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
        # Manual creator tag removed from the form is now deleted...
        self.assertFalse(
            TagList.objects.filter(project=self.project, tag__name='lizo').exists()
        )
        # ...while the account-backed owner creator tag survives.
        self.assertTrue(
            TagList.objects.filter(project=self.project, tag__name='alice').exists()
        )

    def test_edit_keeps_manual_creator_tag_when_resubmitted(self):
        self.project.sync_creator_tags()
        self._add_manual_creator_tag('lizo')
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:project_edit', args=['test-pack']),
            {'title': 'Test Pack', 'category': 'mod', 'description': 'x',
             'tags': 'creator:lizo species:dragon'},
        )
        self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
        self.assertTrue(
            TagList.objects.filter(project=self.project, tag__name='lizo').exists()
        )
        self.assertTrue(
            TagList.objects.filter(project=self.project, tag__name='dragon').exists()
        )


class UGCBrowseTests(UGCGatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)
        self.bob = User.objects.create_user(username='Bob', password='pw')
        UserProfile.objects.get_or_create(user=self.bob)

    def _seed(self):
        p1 = Project.objects.create(slug='furry-mod', title='Furry Mod', category='mod', owner=self.alice, summary='Dragon fun')
        p2 = Project.objects.create(slug='skin-pack', title='Skin Pack', category='skin', owner=self.bob)
        p1.sync_creator_tags()
        # Tag p1: species:dragon, content:threesome. Tag p2: species:dragon only.
        for cat_slug, name in [('species', 'dragon'), ('content', 'threesome')]:
            cat = TagCategory.objects.get(slug=cat_slug)
            tag = Tag.objects.get_or_create(name=name, category=cat)[0]
            TagList.objects.create(project=p1, tag=tag, added_by=self.alice)
        cat = TagCategory.objects.get(slug='species')
        tag = Tag.objects.get_or_create(name='dragon', category=cat)[0]
        TagList.objects.create(project=p2, tag=tag, added_by=self.bob)
        return p1, p2

    def test_browse_gated(self):
        resp = self.client.get(reverse('library:browse'))
        self.assertEqual(resp.status_code, 302)

    def test_browse_lists_projects(self):
        self._seed()
        self.gate()
        resp = self.client.get(reverse('library:browse'))
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, 'Furry Mod')
        self.assertContains(resp, 'Skin Pack')

    def test_browse_search(self):
        self._seed()
        self.gate()
        resp = self.client.get(reverse('library:browse'), {'q': 'Furry'})
        self.assertContains(resp, 'Furry Mod')
        self.assertNotContains(resp, 'Skin Pack')

    def test_browse_category_filter(self):
        self._seed()
        self.gate()
        resp = self.client.get(reverse('library:browse'), {'category': 'skin'})
        self.assertContains(resp, 'Skin Pack')
        self.assertNotContains(resp, 'Furry Mod')

    def test_browse_tag_filter_and(self):
        self._seed()
        self.gate()
        resp = self.client.get(reverse('library:browse'), {'tag': ['dragon', 'threesome']})
        self.assertContains(resp, 'Furry Mod')
        self.assertNotContains(resp, 'Skin Pack')

    def test_browse_tag_filter_single(self):
        self._seed()
        self.gate()
        resp = self.client.get(reverse('library:browse'), {'tag': ['dragon']})
        self.assertContains(resp, 'Furry Mod')
        self.assertContains(resp, 'Skin Pack')

    def test_browse_sort_recent(self):
        self._seed()
        self.gate()
        resp = self.client.get(reverse('library:browse'))
        self.assertEqual(resp.status_code, 200)
        # Skin Pack created last → first card.
        html = resp.content.decode()
        self.assertLess(html.find('Skin Pack'), html.find('Furry Mod'))

    def test_home_shows_real_projects(self):
        self._seed()
        self.gate()
        resp = self.client.get(reverse('landing:home'))
        self.assertContains(resp, 'Furry Mod')
        self.assertContains(resp, 'Skin Pack')


class UGCApiTests(UGCMediaTestCase, UGCGatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)
        cat = TagCategory.objects.get(slug='species')
        Tag.objects.create(name='dragon', category=cat, created_by=self.alice)
        Tag.objects.create(name='dragoness', category=cat, created_by=self.alice)
        self.token = secrets.token_urlsafe(48)
        ApiToken.objects.create(user=self.alice, token=self.token, key_prefix=self.token[:8], label='test')

    def test_tags_autocomplete_gated(self):
        resp = self.client.get(reverse('library:api_tags_autocomplete'), {'q': 'dra'})
        self.assertEqual(resp.status_code, 401)

    def test_tags_autocomplete_with_token(self):
        resp = self.client.get(
            reverse('library:api_tags_autocomplete'), {'q': 'dra'},
            HTTP_AUTHORIZATION=f'Bearer Alice {self.token}',
        )
        self.assertEqual(resp.status_code, 200)
        self.assertJSONEqual(resp.content, [
            {'name': 'dragon', 'category': 'species', 'category_name': 'Species', 'color': '#06a77d'},
            {'name': 'dragoness', 'category': 'species', 'category_name': 'Species', 'color': '#06a77d'},
        ])

    def test_users_autocomplete(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.get(reverse('library:api_users_autocomplete'), {'q': 'Ali'})
        self.assertEqual(resp.status_code, 200)
        self.assertJSONEqual(resp.content, [{'username': 'Alice'}])


def _zip_bytes(entries):
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, 'w') as zf:
        for name, content in entries.items():
            zf.writestr(name, content)
    return buf.getvalue()


def _pack_zip():
    return _zip_bytes({
        'pack.mcmeta': json.dumps({
            'pack': {'pack_format': 64, 'description': 'A test pack'},
            'animationframework': {'id': 'jakebreath:test'},
        }),
    })


class UGCZipParsingTests(TestCase):
    def _write_zip(self, data, suffix='.zip'):
        with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
            f.write(data)
        return f.name

    def test_read_pack_mcmeta(self):
        from library.zips import read_pack_mcmeta
        path = self._write_zip(_pack_zip())
        try:
            fmt, desc = read_pack_mcmeta(path)
        finally:
            os.unlink(path)
        self.assertEqual(fmt, 64)
        self.assertEqual(desc, 'A test pack')

    def test_read_pack_mcmeta_missing(self):
        from library.zips import read_pack_mcmeta
        path = self._write_zip(_zip_bytes({'assets/x.txt': 'x'}))
        try:
            fmt, desc = read_pack_mcmeta(path)
        finally:
            os.unlink(path)
        self.assertIsNone(fmt)
        self.assertEqual(desc, '')

    def test_parse_mods_folder(self):
        from library.zips import parse_mods_manifest
        path = self._write_zip(_zip_bytes({
            'minecraft/mods/a.jar': 'x', 'minecraft/mods/b.jar': 'y',
            'pack.mcmeta': '{}',
        }))
        try:
            manifest = parse_mods_manifest(path, 'pack.zip')
        finally:
            os.unlink(path)
        self.assertEqual(manifest['source'], 'folder')
        self.assertEqual([m['name'] for m in manifest['files']], ['a.jar', 'b.jar'])

    def test_parse_mods_mrpack(self):
        from library.zips import parse_mods_manifest
        path = self._write_zip(_zip_bytes({
            'modrinth.index.json': json.dumps({
                'files': [
                    {'path': 'mods/mod-a.jar', 'env': {'client': 'required', 'server': 'required'}},
                ],
            }),
        }), suffix='.mrpack')
        try:
            manifest = parse_mods_manifest(path, 'pack.mrpack')
        finally:
            os.unlink(path)
        self.assertEqual(manifest['source'], 'modrinth')
        self.assertEqual(manifest['files'][0]['name'], 'mod-a.jar')

    def test_parse_mods_curseforge(self):
        from library.zips import parse_mods_manifest
        path = self._write_zip(_zip_bytes({
            'manifest.json': json.dumps({
                'files': [{'projectID': 123, 'fileID': 456, 'fileName': 'cool-mod.jar'}],
            }),
        }))
        try:
            manifest = parse_mods_manifest(path, 'pack.zip')
        finally:
            os.unlink(path)
        self.assertEqual(manifest['source'], 'curseforge')
        self.assertEqual(manifest['files'][0]['name'], 'cool-mod.jar')


class UGCCategoryTests(UGCMediaTestCase, UGCGatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)

    def _upload_version(self, name, content, content_type='application/zip'):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type=content_type)},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.assertEqual(resp.status_code, 200)

    def _upload_version_form(self, version_name='1.0.0'):
        resp = self.client.post(
            reverse('library:version_upload', args=[self.project.slug]),
            {'version_name': version_name},
        )
        self.assertRedirects(resp, reverse('library:project_detail', args=[self.project.slug]))

    def test_non_pack_captures_pack_mcmeta(self):
        self.project = Project.objects.create(
            slug='non-pack', title='NoN Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('pack.zip', _pack_zip())
        self._upload_version_form()
        version = self.project.versions.first()
        self.assertEqual(version.pack_format, 64)
        self.assertEqual(version.pack_description, 'A test pack')

    def test_modpack_captures_mods_manifest(self):
        self.project = Project.objects.create(
            slug='modpack', title='Modpack', category='modpack', owner=self.alice,
        )
        self._upload_version('pack.zip', _zip_bytes({'minecraft/mods/a.jar': 'x'}))
        self._upload_version_form()
        version = self.project.versions.first()
        self.assertEqual(version.mods_manifest['source'], 'folder')
        self.assertEqual(version.mods_manifest['files'][0]['name'], 'a.jar')

    def test_guide_uploads_md_and_zip_and_renders(self):
        self.project = Project.objects.create(
            slug='guide', title='Guide', category='guide', owner=self.alice,
        )
        self._upload_version('README.md', b'# Title\n\nBody text.', content_type='text/markdown')
        self._upload_version('assets.zip', _zip_bytes({'data/x.json': '{}'}))
        self._upload_version_form()
        version = self.project.versions.first()
        self.assertEqual(
            set(version.files.values_list('kind', flat=True)), {'markdown', 'release'}
        )
        md_vf = version.files.get(kind='markdown')
        # guide_doc endpoint renders the markdown server-side.
        self.gate()
        resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, md_vf.file.uuid]))
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, '<h1>Title</h1>', html=True)
        # Detail page exposes the guide switcher.
        resp = self.client.get(reverse('library:project_detail', args=['guide']))
        self.assertContains(resp, 'guide-switcher')
        self.assertContains(resp, 'README.md')

    def test_guide_doc_rejects_non_markdown(self):
        self.project = Project.objects.create(
            slug='guide', title='Guide', category='guide', owner=self.alice,
        )
        self._upload_version('pack.zip', _zip_bytes({'data/x.json': '{}'}))
        self._upload_version_form()
        version = self.project.versions.first()
        vf = version.files.get(kind='release')
        self.gate()
        resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, vf.file.uuid]))
        self.assertEqual(resp.status_code, 404)

    def test_guide_rewrites_links_to_separately_uploaded_files(self):
        self.project = Project.objects.create(
            slug='guide', title='Guide', category='guide', owner=self.alice,
        )
        self._upload_version(
            'README.md', b'# Title\n\n![logo](images/logo.png)\n', content_type='text/markdown',
        )
        self._upload_version('images/logo.png', b'PNGDATA', content_type='image/png')
        self._upload_version_form()
        version = self.project.versions.first()
        md_vf = version.files.get(kind='markdown')
        self.gate()
        resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, md_vf.file.uuid]))
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, '<img src="/api/files/', count=1)
        self.assertNotContains(resp, 'images/logo.png')

    def test_guide_zip_docs_listed_and_links_rewritten(self):
        self.project = Project.objects.create(
            slug='guide', title='Guide', category='guide', owner=self.alice,
        )
        self._upload_version('guide.zip', _zip_bytes({
            'README.md': '# Read\n\n![pic](images/a.png)\n\nNext: [part two](part2.md)\n',
            'images/a.png': b'PNG2',
            'part2.md': '# Two\n',
        }))
        self._upload_version_form()
        version = self.project.versions.first()
        self.gate()
        # Both .md members appear in the guide switcher.
        resp = self.client.get(reverse('library:project_detail', args=['guide']))
        self.assertContains(resp, 'README.md')
        self.assertContains(resp, 'part2.md')
        # Zip doc renders with image + cross-doc link rewritten.
        resp = self.client.get(reverse('library:guide_zip_doc', args=['guide', version.pk, 'README.md']))
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, '<img src="/packs/guide/versions/%d/asset/images/a.png"' % version.pk, count=1)
        self.assertContains(resp, 'href="/packs/guide/guide/zip/%d/part2.md"' % version.pk, count=1)

    def test_guide_zip_doc_rejects_non_guide_or_missing(self):
        self.project = Project.objects.create(
            slug='guide', title='Guide', category='guide', owner=self.alice,
        )
        self._upload_version('guide.zip', _zip_bytes({'README.md': '# Read\n'}))
        self._upload_version_form()
        version = self.project.versions.first()
        self.gate()
        self.assertEqual(
            self.client.get(reverse('library:guide_zip_doc', args=['guide', version.pk, 'missing.md'])).status_code,
            404,
        )
        self.assertEqual(
            self.client.get(reverse('library:guide_zip_doc', args=['guide', version.pk, 'data.json'])).status_code,
            404,
        )

    def test_guide_links_not_rewritten_outside_guide_category(self):
        self.project = Project.objects.create(
            slug='pack', title='Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version(
            'notes.md', b'# Notes\n\n![x](images/x.png)\n', content_type='text/markdown',
        )
        self._upload_version_form()
        version = self.project.versions.first()
        md_vf = version.files.get(kind='markdown')
        self.gate()
        resp = self.client.get(reverse('library:guide_doc', args=['pack', version.pk, md_vf.file.uuid]))
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, 'src="images/x.png"')

    def test_regular_category_has_no_extra_metadata(self):
        self.project = Project.objects.create(
            slug='mod', title='Mod', category='mod', owner=self.alice,
        )
        self._upload_version('pack.zip', _pack_zip())
        self._upload_version_form()
        version = self.project.versions.first()
        self.assertIsNone(version.pack_format)
        self.assertEqual(version.mods_manifest, {})


def _animation_pack_zip(with_id=True, entity_x_player=False, problem=True):
    actor_a = {
        'label': 'actor1', 'entity_types': ['minecraft:player'],
        'actor_tags': ['gender.male'], 'activity': 'active', 'injector': 'V',
    }
    actor_b = {
        'label': 'actor2',
        'entity_types': ['minecraft:zombie'] if entity_x_player else ['minecraft:player'],
        'actor_tags': [] if entity_x_player else ['gender.female'],
        'activity': 'passive', 'receiver': True,
    }
    content_tags = ['missionary'] + (['bugged'] if problem else [])
    af = {
        'id': 'jakebreath:testpack', 'name': 'Test Pack', 'author': 'Alice, Bob',
        'version': '1.0.0', 'description': 'A test pack.',
    } if with_id else {'name': 'Test Pack', 'author': 'Alice'}
    return _zip_bytes({
        'pack.mcmeta': json.dumps({'pack': {'pack_format': 64}, 'animationframework': af}),
        'data/jakebreath/afw_animdefs/ground.json': json.dumps({
            'display_name': 'Ground', 'content_tags': content_tags,
            'actors': [actor_a, actor_b],
            'stages': [{'stage': 1, 'loop': True, 'cycle_seconds': 1.0},
                       {'stage': 2, 'loop': False, 'non_peak': True}],
        }),
    })


def _bom_pack_zip():
    mcmeta = b'\xef\xbb\xbf' + json.dumps({
        'pack': {'pack_format': 64},
        'animationframework': {'id': 'needsofnature:default', 'name': 'Default', 'author': 'NoN Team'},
    }).encode()
    return _zip_bytes({
        'pack.mcmeta': mcmeta,
        'data/needsofnature/afw_animdefs/solo.json': json.dumps({
            'actors': [{'label': 'actor1', 'entity_types': ['minecraft:player'], 'activity': 'passive'}],
            'content_tags': ['solo'],
            'stages': [{'stage': 1, 'loop': True}],
        }),
    })


class UGCAnimationTests(UGCMediaTestCase, UGCGatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)

    def _write_zip(self, data):
        with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as f:
            f.write(data)
        return f.name

    def _upload_version(self, slug, name, content):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type='application/zip')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.assertEqual(resp.status_code, 200)
        return self.client.post(
            reverse('library:version_upload', args=[slug]), {'version_name': '1.0.0'},
        )

    def test_read_animation_manifest_player_x_player(self):
        from library.zips import read_animation_manifest
        path = self._write_zip(_animation_pack_zip())
        try:
            manifest = read_animation_manifest(path)
        finally:
            os.unlink(path)
        self.assertEqual(manifest['animation_id'], 'jakebreath:testpack')
        self.assertEqual(manifest['authors'], ['Alice', 'Bob'])
        anim = manifest['animations'][0]
        self.assertEqual(anim['name'], 'Ground')
        self.assertEqual(anim['type'], 'Pair')
        self.assertEqual(anim['content_tags'], ['missionary', 'bugged'])
        self.assertEqual(anim['problem_tags'], ['bugged'])
        self.assertEqual(anim['actors'][0]['injector'], 'V')
        self.assertEqual(anim['actors'][0]['injector_name'], 'Vaginal')
        self.assertEqual(anim['actors'][1]['gender'], 'female')
        self.assertEqual(anim['stages'][1]['climax'], True)

    def test_read_animation_manifest_entity_x_player(self):
        from library.zips import read_animation_manifest
        path = self._write_zip(_animation_pack_zip(entity_x_player=True))
        try:
            manifest = read_animation_manifest(path)
        finally:
            os.unlink(path)
        anim = manifest['animations'][0]
        self.assertEqual(anim['type'], 'Entity x Player')
        self.assertEqual(anim['actors'][1]['entity'], 'Zombie')
        self.assertEqual(anim['entity_names'], ['Zombie'])

    def test_read_animation_manifest_bom_mcmeta(self):
        from library.zips import read_animation_manifest, read_pack_mcmeta
        path = self._write_zip(_bom_pack_zip())
        try:
            fmt, desc = read_pack_mcmeta(path)
            manifest = read_animation_manifest(path)
        finally:
            os.unlink(path)
        self.assertEqual(fmt, 64)
        self.assertEqual(manifest['animation_id'], 'needsofnature:default')
        self.assertEqual(manifest['animations'][0]['type'], 'Solo')

    def test_read_animation_manifest_not_animation(self):
        from library.zips import read_animation_manifest
        path = self._write_zip(_zip_bytes({'data/x.txt': 'x'}))
        try:
            manifest = read_animation_manifest(path)
        finally:
            os.unlink(path)
        self.assertIsNone(manifest)

    def test_version_upload_captures_manifest_and_tags(self):
        self.project = Project.objects.create(
            slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
        )
        resp = self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip())
        self.assertRedirects(resp, reverse('library:project_detail', args=['anim-pack']))
        version = self.project.versions.first()
        self.assertEqual(version.animation_id, 'jakebreath:testpack')
        self.assertEqual(version.animation_manifest['animations'][0]['name'], 'Ground')
        # Auto-created content tags (including the problem tag).
        names = set(TagList.objects.filter(project=self.project).values_list('tag__name', flat=True))
        self.assertIn('missionary', names)
        self.assertIn('bugged', names)

    def test_animation_pack_without_id_gets_injected(self):
        self.project = Project.objects.create(
            slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
        )
        resp = self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip(with_id=False))
        self.assertRedirects(resp, reverse('library:project_detail', args=['anim-pack']),
                             fetch_redirect_response=False)
        version = self.project.versions.first()
        # The missing id is auto-assigned instead of warning.
        self.assertEqual(version.animation_id, f'alice:project_{self.project.pk}')
        self.assertTrue(version.animation_manifest)

    def test_latest_api_returns_version_and_url(self):
        self.project = Project.objects.create(
            slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip())
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack']))
        self.assertEqual(resp.status_code, 200)
        data = resp.json()
        self.assertEqual(data['version'], '1.0.0')
        self.assertEqual(data['pack_format'], 64)
        self.assertIn('/api/files/', data['download_url'])
        self.assertIn('?download=1', data['download_url'])

    def test_latest_api_404_unknown_pack(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.get(reverse('library:api_packs_latest', args=['nope', 'missing']))
        self.assertEqual(resp.status_code, 404)

    def test_latest_api_404_without_animation_id(self):
        self.project = Project.objects.create(
            slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip(with_id=False))
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack']))
        self.assertEqual(resp.status_code, 404)


def _geo_model_zip():
    geo = {
        'format_version': '1.12.0',
        'afw_bone_textures': {'body': 'needsofnature:textures/entity/zombie/zombie.png'},
        'minecraft:geometry': [{
            'description': {'identifier': 'geometry.unknown', 'texture_width': 64, 'texture_height': 64},
            'bones': [
                {'name': 'root', 'pivot': [0, 0, 0]},
                {'name': 'body', 'pivot': [0, 12, 0], 'cubes': [
                    {'origin': [-4, 0, -4], 'size': [8, 8, 8], 'uv': [0, 0]},
                ]},
            ],
        }],
    }
    return _zip_bytes({
        'pack.mcmeta': json.dumps({
            'pack': {'pack_format': 88},
            'animationframework': {'id': 'christmaspuffin:models_and_textures', 'name': 'Models',
                                   'author': 'CP', 'version': '1.0'},
        }),
        'assets/animationframework/geckolib/models/entity/zombie.m.geo.json': json.dumps(geo),
        'assets/needsofnature/textures/entity/zombie/zombie.png': b'PNG-DATA',
    })


def _logical_zip():
    return _zip_bytes({
        'pack.mcmeta': json.dumps({'pack': {'pack_format': 61}}),
        'assets/usefulcum/items/potion_gender.json': json.dumps(
            {'model': {'type': 'minecraft:model', 'model': 'usefulcum:item/potion_gender'}}),
        'assets/usefulcum/textures/item/potion_gender.png': b'PNG',
        'data/usefulcum/recipe/cum_combination.json': json.dumps({
            'type': 'minecraft:crafting_shapeless',
            'ingredients': ['minecraft:potion', 'minecraft:potion'],
            'result': {'id': 'minecraft:glass_bottle', 'count': 2},
        }),
        'data/usefulcum/advancement/recipe/blaze_cum.json': json.dumps(
            {'rewards': {'function': 'usefulcum:recipe/blaze_cum'}}),
        'data/usefulcum/advancement/change_gender.json': json.dumps(
            {'rewards': {'function': 'usefulcum:change_gender'}}),
        'data/usefulcum/function/change_gender.mcfunction': b'# x',
        'data/usefulcum/function/tick.mcfunction': b'# y',
    })


class UGCContentTabsTests(UGCMediaTestCase, UGCGatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)

    def _write_zip(self, data):
        with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as f:
            f.write(data)
        return f.name

    def _upload_version(self, slug, name, content):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type='application/zip')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.assertEqual(resp.status_code, 200)
        return self.client.post(
            reverse('library:version_upload', args=[slug]), {'version_name': '1.0.0'},
        )

    def test_read_pack_meta_without_animdefs(self):
        from library.zips import read_pack_meta
        path = self._write_zip(_geo_model_zip())
        try:
            meta = read_pack_meta(path)
        finally:
            os.unlink(path)
        self.assertEqual(meta['animation_id'], 'christmaspuffin:models_and_textures')
        self.assertEqual(meta['authors'], ['CP'])

    def test_read_models_manifest(self):
        from library.zips import read_models_manifest
        path = self._write_zip(_geo_model_zip())
        try:
            manifest = read_models_manifest(path)
        finally:
            os.unlink(path)
        self.assertIsNotNone(manifest)
        model = manifest['models'][0]
        self.assertEqual(model['name'], 'zombie.m.geo.json')
        self.assertEqual(model['bones'], 2)
        self.assertEqual(model['cubes'], 1)
        self.assertEqual(
            model['bone_textures']['body'],
            'assets/needsofnature/textures/entity/zombie/zombie.png',
        )

    def test_read_logical_manifest(self):
        from library.zips import read_logical_manifest
        path = self._write_zip(_logical_zip())
        try:
            manifest = read_logical_manifest(path)
        finally:
            os.unlink(path)
        self.assertIsNotNone(manifest)
        self.assertEqual(manifest['items'][0]['name'], 'usefulcum:potion_gender')
        self.assertEqual(manifest['recipes'][0]['ingredients'], ['minecraft:potion'])
        self.assertEqual(manifest['recipes'][0]['result'], 'minecraft:glass_bottle x2')
        self.assertEqual(manifest['custom_recipes'][0]['name'], 'blaze_cum')
        self.assertEqual(manifest['advancements'][0]['name'], 'change_gender')
        self.assertEqual(len(manifest['functions']), 2)

    def test_models_pack_gets_id_and_models_tab(self):
        self.project = Project.objects.create(
            slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('models-pack', 'pack.zip', _geo_model_zip())
        version = self.project.versions.first()
        self.assertEqual(version.animation_id, 'christmaspuffin:models_and_textures')
        self.assertTrue(version.models_manifest)
        self.assertFalse(version.animation_manifest)
        self.gate()
        resp = self.client.get(reverse('library:project_detail', args=['models-pack']))
        self.assertContains(resp, 'tab-models')
        self.assertContains(resp, 'Render')
        self.assertNotContains(resp, 'tab-animations')

    def test_logical_pack_shows_only_logical_tab(self):
        self.project = Project.objects.create(
            slug='logical-pack', title='Logical Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('logical-pack', 'pack.zip', _logical_zip())
        version = self.project.versions.first()
        self.assertTrue(version.logical_manifest)
        self.assertFalse(version.models_manifest)
        self.assertFalse(version.animation_manifest)
        self.gate()
        resp = self.client.get(reverse('library:project_detail', args=['logical-pack']))
        self.assertContains(resp, 'tab-logical')
        self.assertNotContains(resp, 'tab-models')
        self.assertNotContains(resp, 'tab-animations')
        self.assertContains(resp, 'usefulcum:potion_gender')

    def test_pack_asset_serves_member(self):
        self.project = Project.objects.create(
            slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('models-pack', 'pack.zip', _geo_model_zip())
        version = self.project.versions.first()
        self.gate()
        resp = self.client.get(reverse(
            'library:pack_asset',
            args=['models-pack', version.pk, 'assets/needsofnature/textures/entity/zombie/zombie.png'],
        ))
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(resp['Content-Type'].split(';')[0], 'image/png')
        self.assertEqual(resp.content, b'PNG-DATA')

    def test_pack_asset_rejects_unknown_member(self):
        self.project = Project.objects.create(
            slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('models-pack', 'pack.zip', _geo_model_zip())
        version = self.project.versions.first()
        self.gate()
        resp = self.client.get(reverse(
            'library:pack_asset',
            args=['models-pack', version.pk, 'assets/nope/nothing.png'],
        ))
        self.assertEqual(resp.status_code, 404)

    def test_gallery_video_is_muted(self):
        from library.storage import store_file
        self.project = Project.objects.create(
            slug='muted', title='Muted', category='mod', owner=self.alice,
        )
        index = store_file(
            self.alice, 'asset',
            SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm'),
            self.project.pk, subdir='gallery',
        )
        ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
        self.gate()
        resp = self.client.get(reverse('library:project_detail', args=['muted']))
        self.assertContains(resp, 'muted')


def make_gif_bytes():
    import PIL.Image

    frames = [PIL.Image.new('RGB', (8, 8), c) for c in ('red', 'green')]
    buf = io.BytesIO()
    frames[0].save(buf, format='GIF', save_all=True, append_images=frames[1:], duration=100, loop=0)
    return buf.getvalue()


class UGCThumbnailTests(UGCMediaTestCase, UGCGatedTestCase):
    @unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg required')
    def test_video_and_gif_thumbnails(self):
        User = get_user_model()
        alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=alice)
        project = Project.objects.create(slug='media', title='Media', category='mod', owner=alice)
        from library.storage import store_file

        gif = ProjectAsset.objects.create(
            project=project, uploaded_by=alice,
            file=store_file(
                alice, 'asset',
                SimpleUploadedFile('anim.gif', make_gif_bytes(), content_type='image/gif'),
                project.pk, subdir='gallery',
            ),
        )
        png = ProjectAsset.objects.create(
            project=project, uploaded_by=alice,
            file=store_file(
                alice, 'asset',
                SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
                project.pk, subdir='gallery',
            ),
        )
        self.gate()
        # Gif gets a cached JPEG thumbnail.
        resp = self.client.get(reverse('library:asset_thumbnail', args=['media', gif.pk]))
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(resp['Content-Type'].split(';')[0], 'image/jpeg')
        self.assertGreater(len(b''.join(resp.streaming_content)), 0)
        # Cached on the second request.
        resp2 = self.client.get(reverse('library:asset_thumbnail', args=['media', gif.pk]))
        self.assertEqual(resp2.status_code, 200)
        # A plain image is not thumbnailed → 404.
        resp3 = self.client.get(reverse('library:asset_thumbnail', args=['media', png.pk]))
        self.assertEqual(resp3.status_code, 404)

    def test_gallery_grid_uses_thumbnail_for_gif(self):
        User = get_user_model()
        alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=alice)
        project = Project.objects.create(slug='media', title='Media', category='mod', owner=alice)
        from library.storage import store_file

        ProjectAsset.objects.create(
            project=project, uploaded_by=alice,
            file=store_file(
                alice, 'asset',
                SimpleUploadedFile('anim.gif', make_gif_bytes(), content_type='image/gif'),
                project.pk, subdir='gallery',
            ),
        )
        self.gate()
        resp = self.client.get(reverse('library:project_detail', args=['media']))
        self.assertContains(resp, '/thumb/')


def _stored_mcmeta(version):
    from django.core.files.storage import default_storage
    release = version.files.filter(kind='release').first()
    with default_storage.open(release.file.stored_path, 'rb') as f:
        with zipfile.ZipFile(f) as zf:
            return json.loads(zf.read('pack.mcmeta').decode('utf-8'))


class UGCAnimationIdTests(UGCMediaTestCase, UGCGatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.alice = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.alice)

    def _write_zip(self, data):
        with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as f:
            f.write(data)
        return f.name

    def _upload_version(self, slug, name, content):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(
            reverse('library:api_upload_temp'),
            {'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type='application/zip')},
            HTTP_X_REQUESTED_WITH='XMLHttpRequest',
        )
        self.assertEqual(resp.status_code, 200)
        return self.client.post(
            reverse('library:version_upload', args=[slug]),
            {'version_name': '1.0.0'},
        )

    def test_inject_merge_and_create(self):
        from library.zips import inject_animationframework
        path = self._write_zip(_logical_zip())
        try:
            ok = inject_animationframework(path, {'id': 'alice:project_1', 'name': 'X',
                                                  'author': 'Alice', 'version': '1.0', 'description': 'y'})
            self.assertTrue(ok)
            with zipfile.ZipFile(path) as zf:
                mc = json.loads(zf.read('pack.mcmeta').decode('utf-8'))
            self.assertEqual(mc['animationframework']['id'], 'alice:project_1')
            self.assertEqual(mc['pack']['pack_format'], 61)  # existing block preserved

            path2 = self._write_zip(_zip_bytes({'data/x.txt': 'x'}))
            inject_animationframework(path2, {'id': 'a:b'})
            with zipfile.ZipFile(path2) as zf:
                mc2 = json.loads(zf.read('pack.mcmeta').decode('utf-8'))
            self.assertEqual(mc2['pack']['pack_format'], 64)
            self.assertEqual(mc2['pack']['supported_formats'], [64, 81])
            self.assertEqual(mc2['pack']['min_format'], 64)
            self.assertEqual(mc2['pack']['max_format'], 81)
            self.assertEqual(mc2['animationframework']['id'], 'a:b')
        finally:
            os.unlink(path)

    def test_version_upload_injects_defaults(self):
        self.project = Project.objects.create(
            slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
        )
        resp = self._upload_version('np', 'pack.zip', _logical_zip())
        self.assertRedirects(resp, reverse('library:project_detail', args=['np']))
        version = self.project.versions.first()
        expected = f'alice:project_{self.project.pk}'
        self.assertEqual(version.animation_id, expected)
        mc = _stored_mcmeta(version)
        self.assertEqual(mc['animationframework']['id'], expected)
        self.assertEqual(mc['animationframework']['name'], 'NoN Pack')
        # latest API works for the injected id.
        self.gate()
        resp = self.client.get(reverse('library:api_packs_latest', args=['alice', f'project_{self.project.pk}']))
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(resp.json()['version'], '1.0.0')
        # Download is not truncated: FileIndex size refreshed after injection.
        release = version.files.first()
        resp = self.client.get(reverse('library:file_request', args=[release.file.uuid]))
        served = b''.join(resp.streaming_content)
        self.assertEqual(int(resp['Content-Length']), release.file.size)
        self.assertEqual(len(served), release.file.size)
        with zipfile.ZipFile(io.BytesIO(served)) as zf:
            self.assertIsNone(zf.testzip())

    def test_version_upload_establishes_from_pack(self):
        self.project = Project.objects.create(
            slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('np', 'pack.zip', _animation_pack_zip())
        version = self.project.versions.first()
        self.assertEqual(version.animation_id, 'jakebreath:testpack')

    def test_version_upload_reuses_project_id(self):
        self.project = Project.objects.create(
            slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('np', 'pack.zip', _animation_pack_zip())  # establishes jakebreath:testpack
        self._upload_version('np', 'pack2.zip', _logical_zip())        # no id → reuse
        versions = list(self.project.versions.order_by('created_at'))
        self.assertEqual(versions[-1].animation_id, 'jakebreath:testpack')

    def test_different_id_requires_confirm(self):
        self.project = Project.objects.create(
            slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('np', 'pack.zip', _animation_pack_zip())  # jakebreath:testpack
        other = _zip_bytes({
            'pack.mcmeta': json.dumps({'pack': {'pack_format': 64},
                                       'animationframework': {'id': 'other:pack'}}),
            'data/other/afw_animdefs/x.json': '{}',
        })
        # Without confirmation → form error, no new version.
        self.gate()
        self.client.login(username='Alice', password='pw')
        self.client.post(reverse('library:api_upload_temp'),
                         {'kind': 'version', 'file': SimpleUploadedFile('other.zip', other, content_type='application/zip')},
                         HTTP_X_REQUESTED_WITH='XMLHttpRequest')
        resp = self.client.post(reverse('library:version_upload', args=['np']), {'version_name': '2.0.0'})
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(self.project.versions.count(), 1)
        # With confirmation → accepted.
        resp = self.client.post(reverse('library:version_upload', args=['np']),
                                {'version_name': '2.0.0', 'confirm_replace_identity': '1'})
        self.assertRedirects(resp, reverse('library:project_detail', args=['np']))
        self.assertEqual(self.project.versions.count(), 2)
        self.assertEqual(self.project.versions.first().animation_id, 'other:pack')

    def test_non_non_pack_not_injected(self):
        self.project = Project.objects.create(
            slug='mod', title='A Mod', category='mod', owner=self.alice,
        )
        self._upload_version('mod', 'pack.zip', _logical_zip())
        version = self.project.versions.first()
        self.assertEqual(version.animation_id, '')
        self.assertNotIn('animationframework', _stored_mcmeta(version))

    def test_regenerate_metadata(self):
        self.project = Project.objects.create(
            slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('np', 'pack.zip', _logical_zip())
        version = self.project.versions.first()
        self.assertTrue(version.animation_id)  # auto-injected at upload
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(reverse('library:regenerate_metadata', args=['np']),
                                {'af_namespace': 'foo', 'af_pack_id': 'bar'})
        self.assertRedirects(resp, reverse('library:project_edit', args=['np']))
        version.refresh_from_db()
        self.assertEqual(version.animation_id, 'foo:bar')
        mc = _stored_mcmeta(version)
        self.assertEqual(mc['animationframework']['id'], 'foo:bar')
        resp = self.client.get(reverse('library:api_packs_latest', args=['foo', 'bar']))
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(resp.json()['version'], '1.0.0')
        # Regenerated download is intact (FileIndex size refreshed).
        release = version.files.first()
        resp = self.client.get(reverse('library:file_request', args=[release.file.uuid]))
        served = b''.join(resp.streaming_content)
        self.assertEqual(int(resp['Content-Length']), release.file.size)
        self.assertEqual(len(served), release.file.size)
        with zipfile.ZipFile(io.BytesIO(served)) as zf:
            self.assertIsNone(zf.testzip())

    def test_regenerate_validates_id(self):
        self.project = Project.objects.create(
            slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
        )
        self._upload_version('np', 'pack.zip', _logical_zip())
        before = self.project.versions.first().animation_id
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(reverse('library:regenerate_metadata', args=['np']),
                                {'af_namespace': 'bad id!', 'af_pack_id': 'bar'})
        self.assertRedirects(resp, reverse('library:project_edit', args=['np']))
        self.project.versions.first().refresh_from_db()
        self.assertEqual(self.project.versions.first().animation_id, before)

    def test_create_injects_defaults(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1]
        self.client.post(reverse('library:api_upload_temp'),
                         {'kind': 'version', 'file': SimpleUploadedFile('pack.zip', _logical_zip(), content_type='application/zip'), 'draft_uuid': uuid},
                         HTTP_X_REQUESTED_WITH='XMLHttpRequest')
        resp = self.client.post(reverse('library:project_create', args=[uuid]), {
            'title': 'New Pack', 'category': 'non_pack', 'version_name': '1.0.0',
        })
        self.assertRedirects(resp, reverse('library:project_detail', args=['new-pack']))
        project = Project.objects.get(slug='new-pack')
        version = project.versions.first()
        self.assertEqual(version.animation_id, f'alice:project_{project.pk}')
