#  ***** BEGIN GPL LICENSE BLOCK *****
#
#   Copyright (C) 2021-2024 Robossembler LLC
#
#   Created by Ilia Kurochkin (brothermechanic)
#   contact:    brothermechanic@yandex.com
#
#   This file is part of Robossembler Framework
#   project repo:    https://gitlab.com/robossembler/framework
#
#   Robossembler Framework is free software;
#   you can redistribute it and/or modify
#   it under the terms of the GNU General Public License
#   as published by the Free Software Foundation; either version 3
#   of the License, or (at your option) any later version.
#
#   This program is distributed in the hope that it will be useful,
#   but WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#   GNU General Public License for more details.
#
#   You should have received a copy of the GNU General Public License
#   along with this program; if not, see <https://www.gnu.org/licenses/>.
#
#  ***** END GPL LICENSE BLOCK *****
#
# coding: utf-8
'''
DESCRIPTION.
Various FreeCAD's analise, tesselation and parsing tools.
'''
__version__ = '0.5'

import difflib
import glob
import logging
import math
import os
import xml.dom.minidom

import FreeCAD
import Part
import MeshPart

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)


def is_object_solid(obj) -> bool:
    '''
    Simple FreeCAD's object test for manifold mawater-tight surface.

    :param obj: part, FreeCAD Part::Feature object
    :returns: boolean of obj solid state
    '''
    if not isinstance(obj, FreeCAD.DocumentObject):
        return False

    if not obj.isDerivedFrom('Part::Feature'):
        return False

    if obj.isDerivedFrom('PartDesign::CoordinateSystem'):
        return False

    if not hasattr(obj, 'Shape'):
        return False

    return obj.Shape.isClosed()


def collect_clones(doc) -> list:
    '''
    This script find equal cad parts in a FreeCAD .FCStd scene.

    :param doc: freecad document by name or current document
    :returns: {'basename': ['c1', 'c2', 'c4']}
    '''
    doc_clones = {}
    parts = [item for item in doc.Objects if is_object_solid(item) if item.Visibility]
    for part in parts:
        # collect clones lists
        item_clones = []
        for other in parts:
            if other == part:
                continue
            if (len(other.Shape.Vertexes) == len(part.Shape.Vertexes)
                and len(other.Shape.Edges) == len(part.Shape.Edges)
                and len(other.Shape.Faces) == len(part.Shape.Faces)):
                if round(other.Shape.Volume, 7) == round(part.Shape.Volume, 7):
                    logger.info('Objects has equal Shapes: %s and %s ',
                                part.Label, other.Label)
                    item_clones.append(other.Label)
        # if part has clones
        if item_clones:
            item_clones.append(part.Label)
            # find common basename for all clones
            idx = 0
            common_name = item_clones[0].lower()
            while idx < len(item_clones):
                match_data = difflib.SequenceMatcher(
                    None, common_name, item_clones[idx].lower()
                ).find_longest_match()
                common_name = common_name[match_data.a:match_data.a + match_data.size]
                idx += 1
            # if names has or hasn't common patterns
            item_base_name = common_name.strip(' .,_-') or item_clones[0]
            # sort list names
            item_clones.sort()
            # add only unical list of clones
            if item_clones not in doc_clones.values():
                doc_clones[item_base_name] = item_clones
    return doc_clones


def tesselation(obj,
                doc,
                adaptive=False,
                linear_deflection=0.1,
                angular_deflection=20,
                tesselation_method='Standard',
                fem_size=50.0) -> list:
    '''
    Perform part's shape tesselation.
    '''
    shape = obj.Shape.copy()
    shape.Placement = obj.Placement.inverse().multiply(shape.Placement)
    mesh_from_shape = doc.addObject('Mesh::Feature', 'mesh_from_shape')
    if tesselation_method == 'Standard':
        mesh_from_shape.Mesh = MeshPart.meshFromShape(
            Shape=shape,
            LinearDeflection=linear_deflection,
            AngularDeflection=math.radians(angular_deflection),
            Relative=False)
    elif tesselation_method == 'FEM':
        mesh_from_shape.Mesh = MeshPart.meshFromShape(
            Shape=shape,
            MaxLength=fem_size)
    else:
        raise TypeError('Wrong tesselation method! '
                        'Standard and FEM methods are supported only!')

    # for solids only
    volume = shape.Volume
    mesh_shape = Part.Shape()
    mesh_shape.makeShapeFromMesh(mesh_from_shape.Mesh.Topology, 0.100000, False)
    mesh_volume = mesh_shape.Volume

    if adaptive:
        deviation = 100 * (volume - mesh_volume) / volume
        if deviation > 0.05:
            logger.info(
                'Percentage tesselation deviation for %s object is %s! '
                'Double increasing mesh detalisation!',
                obj.Label, deviation)
            linear_deflection = linear_deflection / 2
            angular_deflection = angular_deflection / 2
            doc.removeObject('mesh_from_shape')
            doc.recompute()
            tesselation(
                obj, doc, adaptive, linear_deflection, angular_deflection,
                tesselation_method, fem_size)

    return mesh_from_shape


def config_parser(comfig_path, param_path, param_name, param_type=None) -> str:
    '''
    Parse FreeCAD XML confings
    '''
    root_node = xml.dom.minidom.parse(comfig_path).childNodes[0]
    node_names = ['Root'] + param_path.split('/') + [param_name]

    for node_name in node_names:
        for child in root_node.childNodes:
            if hasattr(child, 'getAttribute') and child.getAttribute('Name') == node_name:
                root_node = child
                break
        else:
            return None

    tagname = root_node.tagName

    if param_type is not None and tagname != param_type:
        return None

    if tagname == 'FCText':
        if root_node.firstChild:
            return root_node.firstChild._get_data()
        return ''

    if tagname == 'FCBool':
        return bool(root_node.getAttribute('Value'))

    if tagname in ('FCInt', 'FCUInt'):
        return int(root_node.getAttribute('Value'))

    if tagname == 'FCFloat':
        return float(root_node.getAttribute('Value'))

    return root_node.getAttribute('Value')


def get_material_paths() -> list:
    '''
    Collect all available material paths
    '''
    material_paths = []
    system_dir = os.path.join(
        FreeCAD.getResourceDir(),
        'Mod/Material')
    material_paths.extend(
        [item for item in glob.glob(f'{system_dir}/**', recursive=True)
            if item.endswith('.FCMat')]
    )
    user_dir = os.path.join(
        FreeCAD.getUserAppDataDir(),
        'Mod/Material')
    material_paths.extend(
        [item for item in glob.glob(f'{user_dir}/**', recursive=True)
            if item.endswith('.FCMat')]
    )
    custom_dir = []
    if config_parser(
        FreeCAD.ConfigGet("UserParameter"),
        'BaseApp/Preferences/Mod/Material/Resources',
        'UseMaterialsFromCustomDir', 'FCBool'
    ):
        custom_dir = config_parser(
            FreeCAD.ConfigGet("UserParameter"),
            'BaseApp/Preferences/Mod/Material/Resources',
            'CustomMaterialsDir', 'FCText'
        )
        material_paths.extend(
            [item for item in glob.glob(f'{custom_dir}/**', recursive=True)
                if item.endswith('.FCMat')]
        )
    return material_paths


def hierarchy_tree(obj, dict_tree, clones_dic=None) -> dict:
    '''
    Collect hierarchy tree as dict dict_tree
    {
        'type': '',
        'name': '',
        'base_name': '',
        'pose': [{'loc_xyz': []}, {'rot_xyzw': []}],
        'attributes': [],
        'children': [
            {
                'type': '',
                'name': '',
                'base_name': '',
                'loc_xyz': [],
                'rot_xyzw': [],
                'attributes': [],
                'children': [...]
            },
            {...},
        ]
    }
    '''
    # collect type
    if obj.isDerivedFrom('Part::Feature'):
        if obj.isDerivedFrom('PartDesign::CoordinateSystem'):
            dict_tree['type'] = 'LCS'
        else:
            dict_tree['type'] = 'PART'
    elif obj.isDerivedFrom('App::Part'):
        dict_tree['type'] = 'LOCATOR'
    else:
        return False
    # collect name
    dict_tree['name'] = obj.Label
    # collect base_name
    if clones_dic:
        if obj.isDerivedFrom('Part::Feature') and (
            not obj.isDerivedFrom('PartDesign::CoordinateSystem')):
            dict_tree['base_name'] = obj.Label
            for k, v in clones_dic.items():
                if obj.Label in v:
                    dict_tree['base_name'] = k
    # collect transforms
    dict_tree['pose'] = [
        {'loc_xyz': list(obj.Placement.Base)},
        {'rot_xyzw': list(obj.Placement.Rotation.Q)}
    ]
    # collect attributes
    dict_tree['attributes'] = []
    robossembler_attrs = [attr for attr in dir(obj) if 'Robossembler' in attr]
    if robossembler_attrs:
        for attr in robossembler_attrs:
            dict_tree['attributes'].append({attr: getattr(obj, attr)})
    # collect children for LOCATOR only
    if obj.OutList and obj.isDerivedFrom('App::Part'):
        dict_tree['children'] = []
        for child in obj.OutList:
            # skip hidden objects
            if not child.Visibility:
                continue
            # skip helper objects
            if (child.isDerivedFrom('Part::Feature')
                or child.isDerivedFrom('App::Part')):
                dict_tree['children'].append({})
                hierarchy_tree(child, dict_tree['children'][-1], clones_dic)
    return True
