from django.core.files.base import ContentFile
from django.test import Client

from authentication.models import AccountPreference, ToolshedUser
from authentication.tests import UserTestMixin, SignatureAuthClient, ToolshedTestCase
from files.models import File
from toolshed.models import InventoryItem, ItemProperty, StorageLocation
from toolshed.offlinedata import import_inventory, inventory_rows, rows_to_csv
from toolshed.tests import CategoryTestMixin, LocationTestMixin, PropertyTestMixin, TagTestMixin

anonymous_client = Client()
client = SignatureAuthClient()


class _DeleteTestDataMixin(UserTestMixin, CategoryTestMixin, LocationTestMixin):
    """Shared fixture setup for the delete-data and delete-account test cases."""

    def setUp(self):
        super().setUp()
        self.prepare_users()
        self.prepare_categories()
        self.prepare_locations()

        self.f['local_user1'].friends.add(self.f['local_user2'].public_identity)

        self.f['shared_file'] = File.objects.create(
            file=ContentFile(b'shared', 'shared'), mime_type='text/plain', hash='shared')
        self.f['orphan_file'] = File.objects.create(
            file=ContentFile(b'orphan', 'orphan'), mime_type='text/plain', hash='orphan')

        self.f['item1'] = InventoryItem.objects.create(
            owner=self.f['local_user1'], owned_quantity=1, name='item1', category=self.f['cat1'])
        self.f['item1'].files.add(self.f['orphan_file'])

        self.f['item_other_user'] = InventoryItem.objects.create(
            owner=self.f['local_user2'], owned_quantity=1, name='item2', category=self.f['cat1'])
        self.f['item_other_user'].files.add(self.f['shared_file'])

        self.f['item1'].files.add(self.f['shared_file'])

        AccountPreference.objects.create(user=self.f['local_user1'], key='theme', value='dark')

        self.f['local_user1'].profile_picture = self.f['orphan_file']
        self.f['local_user1'].save()


class DeleteDataTestCase(_DeleteTestDataMixin, ToolshedTestCase):

    def test_delete_data_anonymous(self):
        response = anonymous_client.delete('/api/account_data/')
        self.assertEqual(response.status_code, 403)

    def test_delete_data_removes_all_owned_data_but_keeps_account(self):
        response = client.delete('/api/account_data/', self.f['local_user1'])
        self.assertEqual(response.status_code, 200)

        summary = response.json()
        self.assertEqual(summary['inventory_items'], 1)
        self.assertEqual(summary['locations'], 4)
        self.assertEqual(summary['settings'], 1)
        self.assertEqual(summary['friends'], 1)

        # the account itself survives - this wipes data, it doesn't close the account
        self.f['local_user1'].refresh_from_db()
        self.assertTrue(ToolshedUser.objects.filter(username='testuser1').exists())
        self.assertIsNone(self.f['local_user1'].profile_picture)

        self.assertFalse(InventoryItem.global_objects.filter(owner_id=self.f['local_user1'].id).exists())
        self.assertFalse(StorageLocation.objects.filter(owner_id=self.f['local_user1'].id).exists())
        self.assertFalse(AccountPreference.objects.filter(user_id=self.f['local_user1'].id).exists())
        self.assertEqual(self.f['local_user1'].public_identity.friends.count(), 0)

        # orphaned file (only referenced by the deleted user/items) is gone
        self.assertFalse(File.objects.filter(hash='orphan').exists())
        # file still referenced by the other user's item survives
        self.assertTrue(File.objects.filter(hash='shared').exists())

        # the other user's data and identity/friend relation to the deleted identity are untouched
        self.f['local_user2'].refresh_from_db()
        self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists())


class DeleteAccountTestCase(_DeleteTestDataMixin, ToolshedTestCase):

    def test_delete_account_anonymous(self):
        response = anonymous_client.delete('/api/account/')
        self.assertEqual(response.status_code, 403)

    def test_delete_account_removes_data_and_closes_account(self):
        user1_id = self.f['local_user1'].id
        identity_id = self.f['local_user1'].public_identity_id

        response = client.delete('/api/account/', self.f['local_user1'])
        self.assertEqual(response.status_code, 200)

        summary = response.json()
        self.assertEqual(summary['inventory_items'], 1)
        self.assertEqual(summary['locations'], 4)
        self.assertEqual(summary['settings'], 1)
        self.assertEqual(summary['friends'], 1)
        self.assertTrue(summary['account'])

        # the account itself is gone
        self.assertFalse(ToolshedUser.objects.filter(id=user1_id).exists())
        self.assertFalse(InventoryItem.global_objects.filter(owner_id=user1_id).exists())
        self.assertFalse(StorageLocation.objects.filter(owner_id=user1_id).exists())
        self.assertFalse(AccountPreference.objects.filter(user_id=user1_id).exists())

        # the underlying identity is kept, so remote friends/history referencing it stay intact
        from authentication.models import KnownIdentity
        self.assertTrue(KnownIdentity.objects.filter(id=identity_id).exists())

        # orphaned file (only referenced by the deleted user/items) is gone
        self.assertFalse(File.objects.filter(hash='orphan').exists())
        # file still referenced by the other user's item survives
        self.assertTrue(File.objects.filter(hash='shared').exists())

        # the other user's data and identity/friend relation to the deleted identity are untouched
        self.f['local_user2'].refresh_from_db()
        self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists())
        self.assertEqual(self.f['local_user2'].public_identity.friends.count(), 0)


class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
                                         ToolshedTestCase):
    """Properties must round-trip through export/import even when their value contains a
    comma or an '=' sign - characters that a naive "handle=value, handle2=value2" encoding of
    the 'properties' CSV cell would misinterpret as a field/entry separator.
    """

    def setUp(self):
        super().setUp()
        self.prepare_users()
        self.prepare_categories()
        self.prepare_tags()
        self.prepare_properties()

    def test_property_values_with_comma_and_equals_round_trip(self):
        item = InventoryItem.objects.create(owner=self.f['local_user1'], name='widget')
        ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm')
        ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b')

        csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))

        imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
        self.assertEqual(errors, [])
        self.assertEqual(imported, 1)

        new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='widget')
        values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
        self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'})

    def test_legacy_comma_equals_format_is_still_importable(self):
        handle1 = self.f['prop1'].get_handle()
        handle2 = self.f['prop2'].get_handle()
        csv_data = (
            'name,properties\r\n'
            f'legacy widget,"{handle1}=value1, {handle2}=value2"\r\n'
        ).encode('utf-8')

        imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={})
        self.assertEqual(errors, [])
        self.assertEqual(imported, 1)

        item = InventoryItem.objects.get(owner=self.f['local_user1'], name='legacy widget')
        values = {ip.property.name: ip.value for ip in item.itemproperty_set.select_related('property')}
        self.assertEqual(values, {'prop1': 'value1', 'prop2': 'value2'})

    def test_item_without_properties_imports_cleanly(self):
        item = InventoryItem.objects.create(owner=self.f['local_user1'], name='bare item')

        csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))

        imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
        self.assertEqual(errors, [])
        self.assertEqual(imported, 1)

        new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='bare item')
        self.assertEqual(list(new_item.itemproperty_set.all()), [])

    def test_category_and_tags_round_trip(self):
        item = InventoryItem.objects.create(
            owner=self.f['local_user1'], name='cat and tags item', category=self.f['cat1'])
        item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={})

        csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))

        imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
        self.assertEqual(errors, [])
        self.assertEqual(imported, 1)

        new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='cat and tags item')
        self.assertEqual(new_item.category, self.f['cat1'])
        self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2'])

    def test_unknown_property_handle_skips_item_with_error(self):
        csv_data = (
            'name,properties\r\n'
            'ghost widget,test#property:doesnotexist=x\r\n'
        ).encode('utf-8')

        imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={})
        self.assertEqual(imported, 0)
        self.assertEqual(len(errors), 1)
        self.assertIn('doesnotexist', errors[0])
        self.assertFalse(InventoryItem.objects.filter(owner=self.f['local_user1'], name='ghost widget').exists())

    def test_property_value_with_quote_character_round_trips(self):
        item = InventoryItem.objects.create(owner=self.f['local_user1'], name='quoted widget')
        ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='12" screen')

        csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))

        imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
        self.assertEqual(errors, [])
        self.assertEqual(imported, 1)

        new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='quoted widget')
        values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
        self.assertEqual(values, {'prop1': '12" screen'})


class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
                                        ToolshedTestCase):
    """End-to-end coverage of the /api/export/ + /api/import/ endpoints (as actually used by
    clients), rather than calling the internal helper functions directly - this is what a real
    export/import round trip between two accounts looks like.
    """

    def setUp(self):
        super().setUp()
        self.prepare_users()
        self.prepare_categories()
        self.prepare_tags()
        self.prepare_properties()

    def test_export_then_import_preserves_category_tags_and_properties(self):
        import base64

        item = InventoryItem.objects.create(
            owner=self.f['local_user1'], name='drill', description='cordless drill',
            category=self.f['cat1'], availability_policy='friends', owned_quantity=2)
        item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={})
        ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm')
        ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b')

        export_reply = client.get('/api/export/', self.f['local_user1'])
        self.assertEqual(export_reply.status_code, 200)
        zip_bytes = export_reply.content

        import_reply = client.post('/api/import/', self.f['local_user2'],
                                    {'zip': base64.b64encode(zip_bytes).decode('ascii')})
        self.assertEqual(import_reply.status_code, 200)
        summary = import_reply.json()
        self.assertEqual(summary['inventory_items'], 1)
        self.assertEqual(summary['errors'], [])

        new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='drill')
        self.assertEqual(new_item.category, self.f['cat1'])
        self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2'])

        values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
        self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'})

