# SPDX-License-Identifier: GPL-2.0-or-later

import unittest

__all__ = (
    "Clay",
    "MoveLayerCollectionTesting",
    "MoveSceneCollectionSyncTesting",
    "MoveSceneCollectionTesting",
    "ViewLayerTesting",
    "compare_files",
    "dump",
    "get_layers",
    "get_scene_collections",
    "query_scene",
    "setup_extra_arguments",
)

# ############################################################
# Layer Collection Crawler
# ############################################################


def listbase_iter(data, struct, listbase):
    element = data.get_pointer((struct, listbase, b'first'))
    while element is not None:
        yield element
        element = element.get_pointer(b'next')


def linkdata_iter(collection, data):
    element = collection.get_pointer((data, b'first'))
    while element is not None:
        yield element
        element = element.get_pointer(b'next')


def get_layer_collection(layer_collection):
    data = {}
    flag = layer_collection.get(b'flag')

    data['is_visible'] = (flag & (1 << 0)) != 0
    data['is_selectable'] = (flag & (1 << 1)) != 0
    data['is_disabled'] = (flag & (1 << 2)) != 0

    scene_collection = layer_collection.get_pointer(b'scene_collection')
    if scene_collection is None:
        name = 'Fail!'
    else:
        name = scene_collection.get(b'name')
    data['name'] = name

    objects = []
    for link in linkdata_iter(layer_collection, b'object_bases'):
        ob_base = link.get_pointer(b'data')
        ob = ob_base.get_pointer(b'object')
        objects.append(ob.get((b'id', b'name'))[2:])
    data['objects'] = objects

    collections = {}
    for nested_layer_collection in linkdata_iter(layer_collection, b'layer_collections'):
        subname, subdata = get_layer_collection(nested_layer_collection)
        collections[subname] = subdata
    data['collections'] = collections

    return name, data


def get_layer(scene, layer):
    data = {}
    name = layer.get(b'name')

    data['name'] = name
    data['engine'] = scene.get((b'r', b'engine'))

    active_base = layer.get_pointer(b'basact')
    if active_base:
        ob = active_base.get_pointer(b'object')
        data['active_object'] = ob.get((b'id', b'name'))[2:]
    else:
        data['active_object'] = ""

    objects = []
    for link in linkdata_iter(layer, b'object_bases'):
        ob = link.get_pointer(b'object')
        objects.append(ob.get((b'id', b'name'))[2:])
    data['objects'] = objects

    collections = {}
    for layer_collection in linkdata_iter(layer, b'layer_collections'):
        subname, subdata = get_layer_collection(layer_collection)
        collections[subname] = subdata
    data['collections'] = collections

    return name, data


def get_layers(scene):
    """Return all the render layers and their data"""
    layers = {}
    for layer in linkdata_iter(scene, b'view_layers'):
        name, data = get_layer(scene, layer)
        layers[name] = data
    return layers


def get_scene_collection_objects(collection, listbase):
    objects = []
    for link in linkdata_iter(collection, listbase):
        ob = link.get_pointer(b'data')
        if ob is None:
            name = 'Fail!'
        else:
            name = ob.get((b'id', b'name'))[2:]
        objects.append(name)
    return objects


def get_scene_collection(collection):
    """"""
    data = {}
    name = collection.get(b'name')

    data['name'] = name
    data['objects'] = get_scene_collection_objects(collection, b'objects')

    collections = {}
    for nested_collection in linkdata_iter(collection, b'scene_collections'):
        subname, subdata = get_scene_collection(nested_collection)
        collections[subname] = subdata
    data['collections'] = collections

    return name, data


def get_scene_collections(scene):
    """Return all the scene collections ahd their data"""
    master_collection = scene.get_pointer(b'collection')
    return get_scene_collection(master_collection)


def query_scene(filepath, name, callbacks):
    """Return the equivalent to bpy.context.scene"""
    from io_blend_utils.blend import blendfile

    with blendfile.open_blend(filepath) as blend:
        scenes = [block for block in blend.blocks if block.code == b'SC']
        for scene in scenes:
            if scene.get((b'id', b'name'))[2:] != name:
                continue

            return [callback(scene) for callback in callbacks]


# ############################################################
# Utils
# ############################################################

def dump(data):
    import json
    return json.dumps(
        data,
        sort_keys=True,
        indent=4,
        separators=(',', ': '),
    )


# ############################################################
# Tests
# ############################################################

PDB = False
DUMP_DIFF = True
UPDATE_DIFF = False  # HACK used to update tests when something change


def compare_files(file_a, file_b):
    import filecmp

    if not filecmp.cmp(
            file_a,
            file_b):

        if DUMP_DIFF:
            import subprocess
            subprocess.call(["diff", "-u", file_b, file_a])

        if UPDATE_DIFF:
            import subprocess
            subprocess.call(["cp", "-u", file_a, file_b])

        if PDB:
            import pdb
            print("Files differ:", file_b, file_a)
            pdb.set_trace()

        return False

    return True


class ViewLayerTesting(unittest.TestCase):
    _test_simple = False
    _extra_arguments = []

    @classmethod
    def setUpClass(cls):
        """Runs once"""
        cls.pretest_parsing()

    @classmethod
    def get_root(cls):
        """
        return the folder with the test files
        """
        arguments = {}
        for argument in cls._extra_arguments:
            name, value = argument.split('=')
            cls.assertTrue(name and name.startswith("--"), "Invalid argument \"{0}\"".format(argument))
            cls.assertTrue(value, "Invalid argument \"{0}\"".format(argument))
            arguments[name[2:]] = value.strip('"')

        return arguments.get('testdir')

    @classmethod
    def pretest_parsing(cls):
        """
        Test if the arguments are properly set, and store ROOT
        name has extra _ because we need this test to run first
        """
        root = cls.get_root()
        cls.assertTrue(root, "Testdir not set")

    def setUp(self):
        """Runs once per test"""
        import bpy
        bpy.ops.wm.read_factory_settings()

    def path_exists(self, filepath):
        import os
        self.assertTrue(
            os.path.exists(filepath),
            "Test file \"{0}\" not found".format(filepath))

    def do_object_add(self, filepath_json, add_mode):
        """
        Testing for adding objects and see if they
        go to the right collection
        """
        import bpy
        import os
        import tempfile
        import filecmp

        ROOT = self.get_root()
        with tempfile.TemporaryDirectory() as dirpath:
            filepath_layers = os.path.join(ROOT, 'layers.blend')

            # open file
            bpy.ops.wm.open_mainfile('EXEC_DEFAULT', filepath=filepath_layers)
            self.rename_collections()

            # create sub-collections
            three_b = bpy.data.objects.get('T.3b')
            three_c = bpy.data.objects.get('T.3c')

            scene = bpy.context.scene
            subzero = scene.master_collection.collections['1'].collections.new('sub-zero')
            scorpion = subzero.collections.new('scorpion')
            subzero.objects.link(three_b)
            scorpion.objects.link(three_c)
            layer = scene.view_layers.new('Fresh new Layer')
            layer.collections.link(subzero)

            # change active collection
            layer.collections.active_index = 3
            self.assertEqual(layer.collections.active.name, 'scorpion', "Run: test_syncing_object_add")

            # change active layer
            override = bpy.context.copy()
            override["view_layer"] = layer
            override["scene_collection"] = layer.collections.active.collection

            # add new objects
            if add_mode == 'EMPTY':
                bpy.ops.object.add(override)  # 'Empty'

            elif add_mode == 'CYLINDER':
                bpy.ops.mesh.primitive_cylinder_add(override)  # 'Cylinder'

            elif add_mode == 'TORUS':
                bpy.ops.mesh.primitive_torus_add(override)  # 'Torus'

            # save file
            filepath_objects = os.path.join(dirpath, 'objects.blend')
            bpy.ops.wm.save_mainfile('EXEC_DEFAULT', filepath=filepath_objects)

            # get the generated json
            datas = query_scene(filepath_objects, 'Main', (get_scene_collections, get_layers))
            self.assertTrue(datas, "Data is not valid")

            filepath_objects_json = os.path.join(dirpath, "objects.json")
            with open(filepath_objects_json, "w") as f:
                for data in datas:
                    f.write(dump(data))

            self.assertTrue(compare_files(
                filepath_objects_json,
                filepath_json,
            ),
                "Scene dump files differ")

    def do_object_add_no_collection(self, add_mode):
        """
        Test for adding objects when no collection
        exists in render layer
        """
        import bpy

        # empty layer of collections

        layer = bpy.context.view_layer
        while layer.collections:
            layer.collections.unlink(layer.collections[0])

        # add new objects
        if add_mode == 'EMPTY':
            bpy.ops.object.add()  # 'Empty'

        elif add_mode == 'CYLINDER':
            bpy.ops.mesh.primitive_cylinder_add()  # 'Cylinder'

        elif add_mode == 'TORUS':
            bpy.ops.mesh.primitive_torus_add()  # 'Torus'

        self.assertEqual(len(layer.collections), 1, "New collection not created")
        collection = layer.collections[0]
        self.assertEqual(len(collection.objects), 1, "New collection is empty")

    def do_object_link(self, master_collection):
        import bpy
        self.assertEqual(master_collection.name, "Master Collection")
        self.assertEqual(master_collection, bpy.context.scene.master_collection)
        master_collection.objects.link(bpy.data.objects.new('object', None))

    def do_scene_copy(self, filepath_json_reference, copy_mode, data_callbacks):
        import bpy
        import os
        import tempfile
        import filecmp

        ROOT = self.get_root()
        with tempfile.TemporaryDirectory() as dirpath:
            filepath_layers = os.path.join(ROOT, 'layers.blend')

            (self.path_exists(f) for f in (
                filepath_layers,
                filepath_json_reference,
            ))

            filepath_saved = os.path.join(dirpath, '{0}.blend'.format(copy_mode))
            filepath_json = os.path.join(dirpath, "{0}.json".format(copy_mode))

            bpy.ops.wm.open_mainfile('EXEC_DEFAULT', filepath=filepath_layers)
            self.rename_collections()
            bpy.ops.scene.new(type=copy_mode)
            bpy.ops.wm.save_mainfile('EXEC_DEFAULT', filepath=filepath_saved)

            datas = query_scene(filepath_saved, 'Main.001', data_callbacks)
            self.assertTrue(datas, "Data is not valid")

            with open(filepath_json, "w") as f:
                for data in datas:
                    f.write(dump(data))

            self.assertTrue(compare_files(
                filepath_json,
                filepath_json_reference,
            ),
                "Scene copy \"{0}\" test failed".format(copy_mode.title()))

    def do_object_delete(self, del_mode):
        import bpy
        import os
        import tempfile
        import filecmp

        ROOT = self.get_root()
        with tempfile.TemporaryDirectory() as dirpath:
            filepath_layers = os.path.join(ROOT, 'layers.blend')
            filepath_reference_json = os.path.join(ROOT, 'layers_object_delete.json')

            # open file
            bpy.ops.wm.open_mainfile('EXEC_DEFAULT', filepath=filepath_layers)
            self.rename_collections()

            # create sub-collections
            three_b = bpy.data.objects.get('T.3b')
            three_d = bpy.data.objects.get('T.3d')

            scene = bpy.context.scene

            # mangle the file a bit with some objects linked across collections
            subzero = scene.master_collection.collections['1'].collections.new('sub-zero')
            scorpion = subzero.collections.new('scorpion')
            subzero.objects.link(three_d)
            scorpion.objects.link(three_b)
            scorpion.objects.link(three_d)

            # object to delete
            ob = three_d

            # delete object
            if del_mode == 'DATA':
                bpy.data.objects.remove(ob, do_unlink=True)

            elif del_mode == 'OPERATOR':
                bpy.context.view_layer.update()  # update depsgraph
                bpy.ops.object.select_all(action='DESELECT')
                ob.select_set(True)
                self.assertTrue(ob.select_get())
                bpy.ops.object.delete()

            # save file
            filepath_generated = os.path.join(dirpath, 'generated.blend')
            bpy.ops.wm.save_mainfile('EXEC_DEFAULT', filepath=filepath_generated)

            # get the generated json
            datas = query_scene(filepath_generated, 'Main', (get_scene_collections, get_layers))
            self.assertTrue(datas, "Data is not valid")

            filepath_generated_json = os.path.join(dirpath, "generated.json")
            with open(filepath_generated_json, "w") as f:
                for data in datas:
                    f.write(dump(data))

            self.assertTrue(compare_files(
                filepath_generated_json,
                filepath_reference_json,
            ),
                "Scene dump files differ")

    def do_visibility_object_add(self, add_mode):
        import bpy

        scene = bpy.context.scene

        # delete all objects of the file
        for ob in bpy.data.objects:
            bpy.data.objects.remove(ob, do_unlink=True)

        # real test
        layer = scene.view_layers.new('Visibility Test')
        layer.collections.unlink(layer.collections[0])

        scene_collection = scene.master_collection.collections.new("Collection")
        layer.collections.link(scene_collection)

        bpy.context.view_layer.update()  # update depsgraph

        self.assertEqual(len(bpy.data.objects), 0)

        # add new objects
        if add_mode == 'EMPTY':
            bpy.ops.object.add()  # 'Empty'

        elif add_mode == 'CYLINDER':
            bpy.ops.mesh.primitive_cylinder_add()  # 'Cylinder'

        elif add_mode == 'TORUS':
            bpy.ops.mesh.primitive_torus_add()  # 'Torus'

        self.assertEqual(len(bpy.data.objects), 1)

        new_ob = bpy.data.objects[0]
        self.assertTrue(new_ob.visible_get(), "Object should be visible")

    def cleanup_tree(self):
        """
        Remove any existent layer and collections,
        leaving only the one view_layer we can't remove
        """
        import bpy
        scene = bpy.context.scene
        while len(scene.view_layers) > 1:
            scene.view_layers.remove(scene.view_layers[1])

        layer = scene.view_layers[0]
        while layer.collections:
            layer.collections.unlink(layer.collections[0])

        master_collection = scene.master_collection
        while master_collection.collections:
            master_collection.collections.remove(master_collection.collections[0])

    def rename_collections(self, collection=None):
        """
        Rename 'Collection 1' to '1'
        """
        def strip_name(collection):
            import re
            if collection.name.startswith("Default Collection"):
                collection.name = '1'
            else:
                collection.name = re.findall(r'\d+', collection.name)[0]

        if collection is None:
            import bpy
            collection = bpy.context.scene.master_collection

        for nested_collection in collection.collections:
            strip_name(nested_collection)
            self.rename_collections(nested_collection)


class MoveSceneCollectionTesting(ViewLayerTesting):
    """
    To be used by tests of view_layer_move_into_scene_collection
    """

    def get_initial_scene_tree_map(self):
        collections_map = [
            ['A', [
                ['i', None],
                ['ii', None],
                ['iii', None],
            ]],
            ['B', None],
            ['C', [
                ['1', None],
                ['2', None],
                ['3', [
                    ['dog', None],
                    ['cat', None],
                ]],
            ]],
        ]
        return collections_map

    def build_scene_tree(self, tree_map, collection=None, ret_dict=None):
        """
        Returns a flat dictionary with new scene collections
        created from a nested tuple of nested tuples (name, tuple)
        """
        import bpy

        if collection is None:
            collection = bpy.context.scene.master_collection

        if ret_dict is None:
            ret_dict = {collection.name: collection}
            self.assertEqual(collection.name, "Master Collection")

        for name, nested_collections in tree_map:
            new_collection = collection.collections.new(name)
            ret_dict[name] = new_collection

            if nested_collections:
                self.build_scene_tree(nested_collections, new_collection, ret_dict)

        return ret_dict

    def setup_tree(self):
        """
        Cleanup file, and populate it with class scene tree map
        """
        self.cleanup_tree()
        self.assertTrue(
            hasattr(self, "get_initial_scene_tree_map"),
            "Test class has no get_initial_scene_tree_map method implemented")

        return self.build_scene_tree(self.get_initial_scene_tree_map())

    def get_scene_tree_map(self, collection=None, ret_list=None):
        """
        Extract the scene collection tree from scene
        Return as a nested list of nested lists (name, list)
        """
        import bpy

        if collection is None:
            scene = bpy.context.scene
            collection = scene.master_collection

        if ret_list is None:
            ret_list = []

        for nested_collection in collection.collections:
            new_collection = [nested_collection.name, None]
            ret_list.append(new_collection)

            if nested_collection.collections:
                new_collection[1] = list()
                self.get_scene_tree_map(nested_collection, new_collection[1])

        return ret_list

    def compare_tree_maps(self):
        """
        Compare scene with expected (class defined) data
        """
        self.assertEqual(self.get_scene_tree_map(), self.get_reference_scene_tree_map())


class MoveSceneCollectionSyncTesting(MoveSceneCollectionTesting):
    """
    To be used by tests of view_layer_move_into_scene_collection_sync
    """

    def get_initial_layers_tree_map(self):
        layers_map = [
            ['Layer 1', [
                'Master Collection',
                'C',
                '3',
            ]],
            ['Layer 2', [
                'C',
                '3',
                'dog',
                'cat',
            ]],
        ]
        return layers_map

    def get_reference_layers_tree_map(self):
        """
        For those classes we don't expect any changes in the layer tree
        """
        return self.get_initial_layers_tree_map()

    def setup_tree(self):
        tree = super(MoveSceneCollectionSyncTesting, self).setup_tree()

        import bpy
        scene = bpy.context.scene

        self.assertTrue(
            hasattr(self, "get_initial_layers_tree_map"),
            "Test class has no get_initial_layers_tree_map method implemented")

        layers_map = self.get_initial_layers_tree_map()

        for layer_name, collections_names in layers_map:
            layer = scene.view_layers.new(layer_name)
            layer.collections.unlink(layer.collections[0])

            for collection_name in collections_names:
                layer.collections.link(tree[collection_name])

        return tree

    def compare_tree_maps(self):
        """
        Compare scene with expected (class defined) data
        """
        super(MoveSceneCollectionSyncTesting, self).compare_tree_maps()

        import bpy
        scene = bpy.context.scene
        layers_map = self.get_reference_layers_tree_map()

        for layer_name, collections_names in layers_map:
            layer = scene.view_layers.get(layer_name)
            self.assertTrue(layer)
            self.assertEqual(len(collections_names), len(layer.collections))

            for i, collection_name in enumerate(collections_names):
                self.assertEqual(collection_name, layer.collections[i].name)
                self.verify_collection_tree(layer.collections[i])

    def verify_collection_tree(self, layer_collection):
        """
        Check if the LayerCollection mimics the SceneLayer tree
        """
        scene_collection = layer_collection.collection
        self.assertEqual(len(layer_collection.collections), len(scene_collection.collections))

        for i, nested_collection in enumerate(layer_collection.collections):
            self.assertEqual(nested_collection.collection.name, scene_collection.collections[i].name)
            self.assertEqual(nested_collection.collection, scene_collection.collections[i])
            self.verify_collection_tree(nested_collection)


class MoveLayerCollectionTesting(MoveSceneCollectionSyncTesting):
    """
    To be used by tests of view_layer_move_into_layer_collection
    """

    def parse_move(self, path, sep='.'):
        """
        convert 'Layer 1.C.2' into:
        bpy.context.scene.view_layers['Layer 1'].collections['C'].collections['2']
        """
        import bpy

        paths = path.split(sep)
        layer = bpy.context.scene.view_layers[paths[0]]
        collections = layer.collections

        for subpath in paths[1:]:
            collection = collections[subpath]
            collections = collection.collections

        return collection

    def move_into(self, src, dst):
        layer_collection_src = self.parse_move(src)
        layer_collection_dst = self.parse_move(dst)
        return layer_collection_src.move_into(layer_collection_dst)

    def move_above(self, src, dst):
        layer_collection_src = self.parse_move(src)
        layer_collection_dst = self.parse_move(dst)
        return layer_collection_src.move_above(layer_collection_dst)

    def move_below(self, src, dst):
        layer_collection_src = self.parse_move(src)
        layer_collection_dst = self.parse_move(dst)
        return layer_collection_src.move_below(layer_collection_dst)


class Clay:
    def __init__(self, extra_kid_layer=False):
        import bpy

        self._scene = bpy.context.scene
        self._layer = self._fresh_layer()
        self._object = bpy.data.objects.new('guinea pig', bpy.data.meshes.new('mesh'))

        # update depsgraph
        self._layer.update()

        scene_collection_grandma = self._scene.master_collection.collections.new("Grandma")
        scene_collection_mom = scene_collection_grandma.collections.new("Mom")
        scene_collection_kid = scene_collection_mom.collections.new("Kid")
        scene_collection_kid.objects.link(self._object)

        layer_collection_grandma = self._layer.collections.link(scene_collection_grandma)
        layer_collection_mom = layer_collection_grandma.collections[0]
        layer_collection_kid = layer_collection_mom.collections[0]

        # store the variables
        self._scene_collections = {
            'grandma': scene_collection_grandma,
            'mom': scene_collection_mom,
            'kid': scene_collection_kid,
        }
        self._layer_collections = {
            'grandma': layer_collection_grandma,
            'mom': layer_collection_mom,
            'kid': layer_collection_kid,
        }

        if extra_kid_layer:
            layer_collection_extra = self._layer.collections.link(scene_collection_kid)
            self._layer_collections['extra'] = layer_collection_extra

        self._update()

    def _fresh_layer(self):
        import bpy

        # remove all other objects
        while bpy.data.objects:
            bpy.data.objects.remove(bpy.data.objects[0])

        # remove all the other collections
        while self._scene.master_collection.collections:
            self._scene.master_collection.collections.remove(
                self._scene.master_collection.collections[0])

        layer = self._scene.view_layers.new('Evaluation Test')
        layer.collections.unlink(layer.collections[0])
        bpy.context.window.view_layer = layer

        # remove all other layers
        for layer_iter in self._scene.view_layers:
            if layer_iter != layer:
                self._scene.view_layers.remove(layer_iter)

        return layer

    def _update(self):
        """
        Force depsgrpah evaluation
        and update pointers to IDProperty collections
        """
        ENGINE = 'BLENDER_CLAY'

        self._layer.update()  # flush depsgraph evaluation

        # change scene settings
        self._properties = {
            'scene': self._scene.collection_properties[ENGINE],
            'object': self._object.collection_properties[ENGINE],
        }

        for key, value in self._layer_collections.items():
            self._properties[key] = self._layer_collections[key].engine_overrides[ENGINE]

    def get(self, name, data_path):
        self._update()
        return getattr(self._properties[name], data_path)

    def set(self, name, data_path, value):
        self._update()
        self._properties[name].use(data_path)
        setattr(self._properties[name], data_path, value)


def setup_extra_arguments(filepath):
    """
    Create a value which is assigned to: ``UnitTesting._extra_arguments``
    """
    import sys

    extra_arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    sys.argv = [filepath] + extra_arguments[1:]

    return extra_arguments
