import io
import secrets
import shutil
import tempfile

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 library.models import FileIndex

from .models import BIO_MAX_LENGTH, ApiToken, ImpersonationLog, UserProfile


def _read_media(stored_path):
    from django.core.files.storage import default_storage

    with default_storage.open(stored_path, 'rb') as f:
        return f.read()


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

    @classmethod
    def setUpClass(cls):
        cls._media_root = tempfile.mkdtemp(prefix='packs_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 GatedTestCase(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()


class ApiTokenAuthTests(TestCase):
    """Credential auth: 'Authorization: Bearer <username> <token>'."""

    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.token = secrets.token_urlsafe(48)
        ApiToken.objects.create(
            user=self.alice,
            token=self.token,
            key_prefix=self.token[:8],
            label='test',
        )

    def _auth(self, credential=None, username=None, token=None):
        if credential is None:
            credential = f'{username or ""} {token or ""}'.strip()
        return self.client.get(
            '/home/',
            HTTP_AUTHORIZATION=f'Bearer {credential}',
            HTTP_ACCEPT='application/json',
        )

    def test_valid_credentials_pass(self):
        resp = self._auth(username='Alice', token=self.token)
        self.assertEqual(resp.status_code, 200)

    def test_wrong_username_rejected(self):
        # A valid token presented under another user's name must not work.
        resp = self._auth(username='Bob', token=self.token)
        self.assertEqual(resp.status_code, 401)

    def test_wrong_token_rejected(self):
        resp = self._auth(username='Alice', token='x' * 64)
        self.assertEqual(resp.status_code, 401)

    def test_revoked_token_rejected(self):
        ApiToken.objects.all().delete()
        resp = self._auth(username='Alice', token=self.token)
        self.assertEqual(resp.status_code, 401)

    def test_missing_username_rejected(self):
        resp = self._auth(token=self.token)
        self.assertEqual(resp.status_code, 401)

    def test_missing_credentials_rejected(self):
        resp = self._auth()
        self.assertEqual(resp.status_code, 401)

    def test_username_case_insensitive(self):
        resp = self._auth(username='aLiCe', token=self.token)
        self.assertEqual(resp.status_code, 200)

    def test_unknown_user_rejected(self):
        resp = self._auth(username='Nobody', token=self.token)
        self.assertEqual(resp.status_code, 401)

    def test_authenticates_as_owner(self):
        self.client.get(
            '/home/',
            HTTP_AUTHORIZATION=f'Bearer Alice {self.token}',
            HTTP_ACCEPT='application/json',
        )
        token = ApiToken.objects.get(token=self.token)
        self.assertIsNotNone(token.last_used)


class AccountPageTests(MediaTestCase, GatedTestCase):
    def setUp(self):
        User = get_user_model()
        self.user = User.objects.create_user(username='Alice', password='pw')
        UserProfile.objects.get_or_create(user=self.user)

    def test_account_page_requires_login(self):
        self.gate()
        resp = self.client.get(reverse('profiles:account'))
        self.assertEqual(resp.status_code, 302)

    def test_account_page_renders_for_owner(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.get(reverse('profiles:account'))
        self.assertEqual(resp.status_code, 200)

    def test_logout_preserves_gate_access(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(reverse('profiles:logout'))
        self.assertRedirects(resp, '/home/')
        self.assertFalse(self.client.session.get('_auth_user_id'))
        # The gate flag must survive logout.
        self.assertTrue(self.client.session.get('authorized'))
        resp = self.client.get('/home/')
        self.assertEqual(resp.status_code, 200)

    def test_username_change(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(reverse('profiles:account'), {
            'action': 'username',
            'username': 'Alicia',
        })
        self.assertRedirects(resp, reverse('profiles:account'))
        self.assertTrue(get_user_model().objects.filter(username='Alicia').exists())

    def test_username_taken_rejected(self):
        User = get_user_model()
        User.objects.create_user(username='Bob', password='pw')
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(reverse('profiles:account'), {
            'action': 'username',
            'username': 'Bob',
        })
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, 'already taken')

    def test_email_blank_voids_field(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        User = get_user_model()
        User.objects.filter(pk=self.user.pk).update(email='alice@example.com')
        resp = self.client.post(reverse('profiles:account'), {
            'action': 'email',
            'email': '',
        })
        self.assertRedirects(resp, reverse('profiles:account'))
        self.user.refresh_from_db()
        self.assertEqual(self.user.email, '')

    def test_password_change(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(reverse('profiles:account'), {
            'action': 'password',
            'old_password': 'pw',
            'new_password1': 'new-secure-pass-123',
            'new_password2': 'new-secure-pass-123',
        })
        self.assertRedirects(resp, reverse('profiles:account'))
        self.assertTrue(self.client.login(username='Alice', password='new-secure-pass-123'))

    def test_avatar_upload_creates_file_index(self):
        import PIL.Image

        self.gate()
        self.client.login(username='Alice', password='pw')
        buf = io.BytesIO()
        PIL.Image.new('RGB', (8, 8), 'red').save(buf, format='PNG')
        upload = SimpleUploadedFile('pic.png', buf.getvalue(), content_type='image/png')
        resp = self.client.post(reverse('profiles:account'), {
            'action': 'avatar',
            'avatar': upload,
        })
        self.assertRedirects(resp, reverse('profiles:account'))
        self.user.userprofile.refresh_from_db()
        self.assertIsNotNone(self.user.userprofile.avatar)
        fi = FileIndex.objects.get(pk=self.user.userprofile.avatar_id)
        self.assertEqual(fi.kind, 'avatar')
        self.assertTrue(fi.stored_path.endswith('picture.jpeg'))
        self.assertEqual(fi.content_type, 'image/jpeg')
        self.assertEqual(fi.owner, self.user)
        # The indexed file must be reachable through FileRequest.
        url = reverse('library:file_request', args=[fi.uuid])
        self.client.get(url)  # warm auth
        resp = self.client.get(url)
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(b''.join(resp.streaming_content), _read_media(fi.stored_path))


class ProfilePageTests(GatedTestCase):
    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 test_profile_page_renders(self):
        self.gate()
        resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, 'Alice')

    def test_profile_unknown_user_404(self):
        self.gate()
        resp = self.client.get(reverse('profiles:user_profile', args=['Nobody']))
        self.assertEqual(resp.status_code, 404)

    def test_bio_owner_can_edit(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
            'action': 'bio',
            'bio': 'Hello there',
        })
        self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice']))
        self.alice.userprofile.refresh_from_db()
        self.assertEqual(self.alice.userprofile.bio, 'Hello there')

    def test_bio_non_owner_cannot_edit(self):
        self.gate()
        self.client.login(username='Bob', password='pw')
        resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
            'action': 'bio',
            'bio': 'Hacked',
        })
        # Non-owner must not be able to rewrite the bio.
        self.alice.userprofile.refresh_from_db()
        self.assertEqual(self.alice.userprofile.bio, '')

    def test_bio_over_max_length_rejected(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        oversized = 'x' * (BIO_MAX_LENGTH + 1)
        resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
            'action': 'bio',
            'bio': oversized,
        })
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, 'at most')
        self.alice.userprofile.refresh_from_db()
        self.assertEqual(self.alice.userprofile.bio, '')

    def test_bio_at_max_length_accepted(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        at_limit = 'y' * BIO_MAX_LENGTH
        resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
            'action': 'bio',
            'bio': at_limit,
        })
        self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice']))
        self.alice.userprofile.refresh_from_db()
        self.assertEqual(len(self.alice.userprofile.bio), BIO_MAX_LENGTH)

    def test_bio_special_symbols_round_trip(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        # Emoji, quotes, ampersands, HTML, unicode — must all round-trip.
        fancy = 'Café <b>&"\'</b> 😀 line\nbreak\ttab 100% +plus?'
        resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
            'action': 'bio',
            'bio': fancy,
        })
        self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice']))
        self.alice.userprofile.refresh_from_db()
        self.assertEqual(self.alice.userprofile.bio, fancy)

    def _set_bio(self, text):
        self.alice.userprofile.bio = text
        self.alice.userprofile.save()

    def test_bio_renders_markdown(self):
        self._set_bio('# Hi\n\n**bold** and *italic* and `code`')
        self.gate()
        resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
        self.assertContains(resp, '<h1>Hi</h1>', html=True)
        self.assertContains(resp, '<strong>bold</strong>', html=True)
        self.assertContains(resp, '<em>italic</em>', html=True)

    def test_bio_raw_html_escaped(self):
        self._set_bio('<script>alert(1)</script>')
        self.gate()
        resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
        self.assertNotContains(resp, '<script>alert(1)</script>', html=True)
        self.assertContains(resp, '&lt;script&gt;alert(1)&lt;/script&gt;')

    def test_bio_dangerous_link_scheme_rejected(self):
        self._set_bio('[x](javascript:alert(1))')
        self.gate()
        resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
        self.assertNotContains(resp, 'href="javascript:alert(1)"')

    def test_bio_gfm_features(self):
        self._set_bio('| a | b |\n|---|---|\n| 1 | 2 |\n\n~~gone~~')
        self.gate()
        resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
        self.assertContains(resp, '<table>', html=False)
        self.assertContains(resp, '<s>gone</s>', html=False)

    def test_bio_preview_requires_login(self):
        self.gate()
        resp = self.client.post(reverse('profiles:bio_preview'), {'bio': '# x'})
        self.assertEqual(resp.status_code, 302)

    def test_bio_preview_renders(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.post(reverse('profiles:bio_preview'), {'bio': '**hi** *there*'})
        self.assertEqual(resp.status_code, 200)
        self.assertJSONEqual(resp.content, {'html': '<p><strong>hi</strong> <em>there</em></p>'})

    def test_bio_preview_get_rejected(self):
        self.gate()
        self.client.login(username='Alice', password='pw')
        resp = self.client.get(reverse('profiles:bio_preview'))
        self.assertEqual(resp.status_code, 405)


class FileRequestTests(MediaTestCase, GatedTestCase):
    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.file_index = FileIndex.objects.create(
            owner=self.alice,
            kind='avatar',
            stored_path='avatars/user_%d/picture.jpeg' % self.alice.pk,
            original_filename='pic.jpeg',
            content_type='image/jpeg',
            size=4,
            md5='abc',
        )
        self.url = reverse('library:file_request', args=[self.file_index.uuid])

    def _write_media(self):
        from django.core.files.storage import default_storage
        default_storage.save(self.file_index.stored_path, io.BytesIO(b'ABCD'))

    def test_full_request_200(self):
        self._write_media()
        self.gate()
        resp = self.client.get(self.url)
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(b''.join(resp.streaming_content), b'ABCD')
        self.assertEqual(resp['Accept-Ranges'], 'bytes')

    def test_byte_range_206(self):
        self._write_media()
        self.gate()
        resp = self.client.get(self.url, HTTP_RANGE='bytes=0-1')
        self.assertEqual(resp.status_code, 206)
        self.assertEqual(b''.join(resp.streaming_content), b'AB')
        self.assertEqual(resp['Content-Range'], 'bytes 0-1/4')

    def test_suffix_range_206(self):
        self._write_media()
        self.gate()
        resp = self.client.get(self.url, HTTP_RANGE='bytes=-2')
        self.assertEqual(resp.status_code, 206)
        self.assertEqual(b''.join(resp.streaming_content), b'CD')

    def test_unsatisfiable_range_416(self):
        self._write_media()
        self.gate()
        resp = self.client.get(self.url, HTTP_RANGE='bytes=99-100')
        self.assertEqual(resp.status_code, 416)
        self.assertEqual(resp['Content-Range'], 'bytes */4')

    def test_media_requires_gate(self):
        self._write_media()
        resp = self.client.get(self.url)
        self.assertEqual(resp.status_code, 401)

    def test_media_accessible_with_token(self):
        self._write_media()
        token = secrets.token_urlsafe(48)
        ApiToken.objects.create(
            user=self.alice,
            token=token,
            key_prefix=token[:8],
            label='test',
        )
        resp = self.client.get(
            self.url,
            HTTP_AUTHORIZATION=f'Bearer Alice {token}',
        )
        self.assertEqual(resp.status_code, 200)

    def test_missing_file_404(self):
        self.gate()
        resp = self.client.get(self.url)
        self.assertEqual(resp.status_code, 404)


class ImpersonationTests(GatedTestCase):
    """Staff impersonation: switch users without logging out, PK-based
    staff-to-staff rule, superusers off-limits, gate state preserved."""

    def setUp(self):
        User = get_user_model()
        # Created in this order → increasing PKs: boss(1) < junior(2) < normal(3) < top(4).
        self.boss = User.objects.create_user(username='Boss', password='pw', is_staff=True)
        UserProfile.objects.get_or_create(user=self.boss)
        self.junior = User.objects.create_user(username='JuniorStaff', password='pw', is_staff=True)
        UserProfile.objects.get_or_create(user=self.junior)
        self.normal = User.objects.create_user(username='NormalUser', password='pw')
        UserProfile.objects.get_or_create(user=self.normal)
        self.top = User.objects.create_superuser(username='TopSuper', password='pw')
        UserProfile.objects.get_or_create(user=self.top)

    def _login(self, user):
        self.gate()
        self.client.login(username=user.username, password='pw')

    def _start(self, user, **extra):
        return self.client.post(
            reverse('profiles:impersonate_start', args=[user.pk]), data=extra,
        )

    @property
    def active_user_id(self):
        value = self.client.session.get('_auth_user_id')
        return int(value) if value else None

    def test_users_page_gated(self):
        resp = self.client.get(reverse('profiles:user_list'))
        self.assertEqual(resp.status_code, 302)

    def test_users_page_lists_all(self):
        self.gate()
        resp = self.client.get(reverse('profiles:user_list'))
        self.assertEqual(resp.status_code, 200)
        for name in ('Boss', 'JuniorStaff', 'NormalUser', 'TopSuper'):
            self.assertContains(resp, name)

    def test_users_page_search(self):
        self.gate()
        resp = self.client.get(reverse('profiles:user_list'), {'q': 'Normal'})
        self.assertContains(resp, 'NormalUser')
        self.assertNotContains(resp, 'Boss')

    def test_impersonate_requires_post(self):
        self._login(self.boss)
        resp = self.client.get(reverse('profiles:impersonate_start', args=[self.normal.pk]))
        self.assertEqual(resp.status_code, 405)

    def test_non_staff_cannot_impersonate(self):
        self._login(self.normal)
        resp = self._start(self.normal)
        self.assertEqual(resp.status_code, 403)
        self.assertEqual(ImpersonationLog.objects.count(), 0)

    def test_staff_impersonates_normal_user(self):
        self._login(self.boss)
        resp = self._start(self.normal, reason='moderation review')
        self.assertRedirects(resp, reverse('profiles:user_profile', args=['NormalUser']))
        self.assertEqual(self.active_user_id, self.normal.pk)
        self.assertEqual(self.client.session.get('impersonator_id'), self.boss.pk)
        self.assertTrue(self.client.session.get('authorized'))
        log = ImpersonationLog.objects.get()
        self.assertEqual(log.impersonator, self.boss)
        self.assertEqual(log.target, self.normal)
        self.assertEqual(log.reason, 'moderation review')
        self.assertIsNone(log.ended_at)

    def test_gate_preserved_while_impersonating(self):
        self._login(self.boss)
        self._start(self.normal)
        # Follow-up page loads render (200) instead of bouncing to the gate.
        resp = self.client.get(reverse('profiles:user_profile', args=['NormalUser']))
        self.assertEqual(resp.status_code, 200)

    def test_cannot_impersonate_self(self):
        self._login(self.boss)
        resp = self._start(self.boss)
        self.assertEqual(resp.status_code, 302)
        self.assertEqual(self.active_user_id, self.boss.pk)
        self.assertNotIn('impersonator_id', self.client.session)
        self.assertEqual(ImpersonationLog.objects.count(), 0)

    def test_older_staff_can_impersonate_newer_staff(self):
        self._login(self.boss)
        self._start(self.junior)
        self.assertEqual(self.active_user_id, self.junior.pk)

    def test_newer_staff_cannot_impersonate_older_staff(self):
        self._login(self.junior)
        self._start(self.boss)
        self.assertEqual(self.active_user_id, self.junior.pk)
        self.assertNotIn('impersonator_id', self.client.session)
        self.assertEqual(ImpersonationLog.objects.count(), 0)

    def test_cannot_impersonate_superuser(self):
        self._login(self.boss)
        self._start(self.top)
        self.assertEqual(self.active_user_id, self.boss.pk)
        self.assertEqual(ImpersonationLog.objects.count(), 0)

    def test_stop_impersonation_restores_original(self):
        self._login(self.boss)
        self._start(self.normal)
        resp = self.client.post(reverse('profiles:impersonate_stop'))
        self.assertRedirects(resp, reverse('landing:home'))
        self.assertEqual(self.active_user_id, self.boss.pk)
        self.assertNotIn('impersonator_id', self.client.session)
        self.assertTrue(self.client.session.get('authorized'))
        log = ImpersonationLog.objects.get()
        self.assertIsNotNone(log.ended_at)

    def test_logout_while_impersonating_stops_instead(self):
        self._login(self.boss)
        self._start(self.normal)
        resp = self.client.post(reverse('profiles:logout'))
        self.assertRedirects(resp, reverse('landing:home'))
        # Back on the real account, still logged in, gate still open.
        self.assertEqual(self.active_user_id, self.boss.pk)
        self.assertNotIn('impersonator_id', self.client.session)
        self.assertTrue(self.client.session.get('authorized'))
        self.assertTrue(ImpersonationLog.objects.get().ended_at)
        # A second logout is a real logout.
        self.client.post(reverse('profiles:logout'))
        self.assertIsNone(self.active_user_id)

    def test_impersonate_button_visibility(self):
        self._login(self.boss)
        resp = self.client.get(reverse('profiles:user_profile', args=['NormalUser']))
        self.assertContains(resp, 'Impersonate')
        # Self → no button.
        resp = self.client.get(reverse('profiles:user_profile', args=['Boss']))
        self.assertNotContains(resp, 'Impersonate')

    def test_impersonate_button_hidden_for_non_staff(self):
        self._login(self.normal)
        resp = self.client.get(reverse('profiles:user_profile', args=['NormalUser']))
        self.assertNotContains(resp, 'Impersonate')

    def test_impersonate_button_hidden_while_impersonating(self):
        self._login(self.boss)
        self._start(self.junior)  # acting as a staff account now
        # Even though the impersonated account is staff, no impersonate button.
        resp = self.client.get(reverse('profiles:user_profile', args=['Boss']))
        self.assertNotContains(resp, 'Impersonate')
