# ***************************************************************************
# *   Copyright (c) 2018 Bernd Hahnebach <bernd@bimstatik.org>              *
# *                                                                         *
# *   This file is part of the FreeCAD CAx development system.              *
# *                                                                         *
# *   This program is free software; you can redistribute it and/or modify  *
# *   it under the terms of the GNU Lesser General Public License (LGPL)    *
# *   as published by the Free Software Foundation; either version 2 of     *
# *   the License, or (at your option) any later version.                   *
# *   for detail see the LICENCE text file.                                 *
# *                                                                         *
# *   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 Library General Public License for more details.                  *
# *                                                                         *
# *   You should have received a copy of the GNU Library General Public     *
# *   License along with this program; if not, write to the Free Software   *
# *   Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  *
# *   USA                                                                   *
# *                                                                         *
# ***************************************************************************

__title__ = "Tools for FEM unit tests"
__author__ = "Bernd Hahnebach"
__url__ = "https://www.freecad.org"

import math
import os
import sys
import tempfile
import unittest
from typing import Union, List, Iterator

import FreeCAD

from os.path import join


def get_fem_test_home_dir():
    return join(FreeCAD.getHomePath(), "Mod", "Fem", "femtest", "data")


def get_fem_test_tmp_dir(dirname=None):
    from uuid import uuid4

    _unique_id = str(uuid4())[-12:]
    # print(_unique_id)
    if dirname is None:
        temp_dir = join(tempfile.gettempdir(), "FEM_unittests", _unique_id)
    else:
        temp_dir = join(tempfile.gettempdir(), "FEM_unittests", dirname + "_" + _unique_id)
    if not os.path.exists(temp_dir):
        os.makedirs(temp_dir)
    return temp_dir


def get_unit_test_tmp_dir(temp_dir, unittestdir):
    testdir = join(temp_dir, unittestdir)
    if not os.path.exists(testdir):
        os.makedirs(testdir)
    return testdir


def fcc_print(message):
    FreeCAD.Console.PrintMessage(f"{message} \n")


def get_namefromdef(strdel="", stradd=""):
    # https://code.activestate.com/recipes/66062-determining-current-function-name/
    return (sys._getframe(1).f_code.co_name).replace(strdel, stradd)


def get_defmake_count(fem_vtk_post=True):
    """
    count the def make in module ObjectsFem
    could also be done in bash with
    grep -c  "def make" src/Mod/Fem/ObjectsFem.py
    """
    name_modfile = join(FreeCAD.getHomePath(), "Mod", "Fem", "ObjectsFem.py")
    modfile = open(name_modfile)
    lines_modefile = modfile.readlines()
    modfile.close()
    lines_defmake = [li for li in lines_modefile if li.startswith("def make")]
    if not fem_vtk_post:
        # FEM VTK post processing is disabled
        # we are not able to create VTK post objects
        new_lines = []
        for li in lines_defmake:
            if "PostVtk" not in li:
                new_lines.append(li)
        lines_defmake = new_lines
    return len(lines_defmake)


def get_fem_test_defs():

    test_path = join(FreeCAD.getHomePath(), "Mod", "Fem", "femtest", "app")
    print(f"Modules, classes, methods taken from: {test_path}")

    collected_test_module_paths = []
    for tfile in sorted(os.listdir(test_path)):
        if tfile.startswith("test") and tfile.endswith(".py"):
            collected_test_module_paths.append(join(test_path, tfile))

    collected_test_modules = []
    collected_test_classes = []
    collected_test_methods = []
    for f in collected_test_module_paths:
        module_name = os.path.splitext(os.path.basename(f))[0]
        module_path = f"femtest.app.{module_name}"
        if module_path not in collected_test_modules:
            collected_test_modules.append(module_path)
        class_name = ""
        tfile = open(f)
        for ln in tfile:
            ln = ln.lstrip()
            ln = ln.rstrip()
            if ln.startswith("class "):
                ln = ln.lstrip("class ")
                ln = ln.split("(")[0]
                class_name = ln
                class_path = f"femtest.app.{module_name}.{class_name}"
                if class_path not in collected_test_classes:
                    collected_test_classes.append(class_path)
            if ln.startswith("def test"):
                ln = ln.lstrip("def ")
                ln = ln.split("(")[0]
                if ln == "test_00print":
                    continue
                method_path = f"femtest.app.{module_name}.{class_name}.{ln}"
                collected_test_methods.append(method_path)
        tfile.close()

    # write to file
    file_path = join(tempfile.gettempdir(), "test_commands.sh")
    cf = open(file_path, "w")
    cf.write("# created by Python\n")
    cf.write("'''\n")
    cf.write("from femtest.app.support_utils import get_fem_test_defs\n")
    cf.write("get_fem_test_defs()\n")
    cf.write("\n")
    cf.write("\n")
    cf.write("# all FEM App tests\n")
    cf.write("make -j 4 && ./bin/FreeCAD --run-test 'TestFemApp'\n")
    cf.write("\n")
    cf.write("make -j 4 && ./bin/FreeCADCmd --run-test 'TestFemApp'\n")
    cf.write("\n")
    cf.write("\n")
    cf.write("'''\n")
    cf.write("\n")
    cf.write("# modules\n")
    for m in collected_test_modules:
        cf.write(f"make -j 4 && ./bin/FreeCADCmd -t {m}\n")
    cf.write("\n")
    cf.write("\n")
    cf.write("# classes\n")
    for m in collected_test_classes:
        cf.write(f"make -j 4 && ./bin/FreeCADCmd -t {m}\n")
    cf.write("\n")
    cf.write("\n")
    cf.write("# methods\n")
    for m in collected_test_methods:
        cf.write(f"make -j 4 && ./bin/FreeCADCmd -t {m}\n")
    cf.write("\n")
    cf.write("\n")
    cf.write("# methods in FreeCAD\n")
    for m in collected_test_methods:
        cf.write(
            "\nimport unittest\n"
            "unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromName(\n"
            "    '{}'\n"
            "))\n".format(m)
        )
    cf.close()
    print(f"The file was saved in:{file_path}")


def try_converting_to_float(line: str) -> Union[None, List[float]]:
    """Does its best to split a line and convert its elements to float

    Has 3 strategies of splitting:
        * by comma - mainly in CalculiX .inp files
        * by space - other solvers
        * by space and ignoring the 1st word - other solvers
            If there was only 1 word, the line will fail, no compromises

    Single characters always pass

    :param line: line to split and convert to floats
    :return: None if conversion failed, else list of floats
    """
    strategies = [
        lambda _line: _line[1:].split(","),
        lambda _line: _line[1:].split(),
        lambda _line: (lambda split_line: len(split_line) > 1 and split_line or "fail")(
            _line[1:].split()[1:]
        ),
    ]
    for strategy in strategies:
        try:
            return list(map(float, strategy(line)))
        except ValueError:
            pass
    return None


def are_floats_equal(
    orig_floats: Union[None, List[float]], floats_to_compare: Union[None, List[float]]
) -> bool:
    """Check if floats in lists are equal with some tolerance

    :param orig_floats: list of floats - left operands of comparison
    :param floats_to_compare: list of floats - right operands of comparison
    :return: True if both lists are equal with some tolerance element-wise, else False
    """
    if any(floats is None for floats in (orig_floats, floats_to_compare)):
        return False
    for orig_float, float_to_compare in zip(orig_floats, floats_to_compare):
        if not math.isclose(orig_float, float_to_compare, abs_tol=1e-10):
            return False
    return True


def parse_diff(diff_lines: Iterator[str]) -> List[str]:
    """Parses lines from `united_diff`

    Tries to split a line and convert its contents to float, to
    compare float numbers with some tolerance

    Recognizes blocks of changes, that start with `@@` and end either at EOF or
    at the beginning of another block. Only bad lines are added to block.
    All lines, which didn't pass the element-wise check, are bad lines.
    :param diff_lines: lines produced by `united_diff`
    :return: list of bad lines with respect to their block of change
    """
    bad_lines = []
    changes_block = []
    while True:
        try:
            first = next(diff_lines)
            if first.startswith("@@"):
                if len(changes_block) > 1:
                    bad_lines.extend(changes_block)
                changes_block = [first]
                continue
            second = next(diff_lines)
        except StopIteration:
            break
        if first.startswith("---"):
            continue
        if not are_floats_equal(*map(try_converting_to_float, (first, second))):
            changes_block.extend((first, second))
    # check if we have any remaining block
    if len(changes_block) > 1:
        bad_lines.extend(changes_block)
    return bad_lines


def compare_inp_files(file_name1, file_name2):
    file1 = open(file_name1)
    f1 = file1.readlines()
    file1.close()
    # l.startswith("17671.0,1") is a temporary workaround
    # for python3 problem with 1DFlow input
    # TODO as soon as the 1DFlow result reading is fixed
    # this should be triggered in the 1DFlow unit test
    lf1 = [
        li
        for li in f1
        if not (
            li.startswith("**   written ")
            or li.startswith("**   file ")
            or li.startswith("17671.0,1")
        )
    ]
    lf1 = force_unix_line_ends(lf1)
    file2 = open(file_name2)
    f2 = file2.readlines()
    file2.close()
    # TODO see comment on file1
    lf2 = [
        li
        for li in f2
        if not (
            li.startswith("**   written ")
            or li.startswith("**   file ")
            or li.startswith("17671.0,1")
        )
    ]
    lf2 = force_unix_line_ends(lf2)
    import difflib

    diff_lines = difflib.unified_diff(lf1, lf2, n=0)

    bad_lines = parse_diff(diff_lines)
    if bad_lines:
        return f"Comparing {file_name1} to {file_name2} failed!\n{''.join(bad_lines)}"


def compare_files(file_name1, file_name2):
    file1 = open(file_name1)
    f1 = file1.readlines()
    file1.close()

    # TODO: add support for variable values in the reference file
    # instead of using this workaround

    # workaround to compare geos of elmer test and temporary file path
    # (not only names change, path changes with operating system)
    lf1 = [
        li
        for li in f1
        if not (
            li.startswith('Merge "')
            or li.startswith('Save "')
            or li.startswith("// ")
            or li.startswith("General.NumThreads")
        )
    ]
    lf1 = force_unix_line_ends(lf1)
    file2 = open(file_name2)
    f2 = file2.readlines()
    file2.close()
    lf2 = [
        li
        for li in f2
        if not (
            li.startswith('Merge "')
            or li.startswith('Save "')
            or li.startswith("// ")
            or li.startswith("General.NumThreads")
        )
    ]
    lf2 = force_unix_line_ends(lf2)
    import difflib

    diff = difflib.unified_diff(lf1, lf2, n=0)
    result = ""
    for li in diff:
        result += li
    if result:
        result = f"Comparing {file_name1} to {file_name2} failed!\n" + result
    return result


def compare_stats(fea, stat_file, res_obj_name, loc_stat_types=None):
    import femresult.resulttools as resulttools

    # get the stat types which should be compared
    stat_types = [
        "U1",
        "U2",
        "U3",
        "Uabs",
        "Sabs",
        "MaxPrin",
        "MidPrin",
        "MinPrin",
        "MaxShear",
        "Peeq",
        "Temp",
        "MFlow",
        "NPress",
    ]
    if not loc_stat_types:
        loc_stat_types = stat_types

    # get stats from result obj which should be compared
    obj = fea.analysis.Document.getObject(res_obj_name)
    # fcc_print(obj)
    if obj:
        # fcc_print(obj.Name)
        stats = []
        for s in loc_stat_types:
            statval = resulttools.get_stats(obj, s)
            stats.append(f"{s}: ({statval[0]:.10f}, {statval[1]:.10f})\n")
    else:
        fcc_print(f"Result object not found. Name: {res_obj_name}")
        return True

    # get stats to compare with, the expected ones
    sf = open(stat_file)
    sf_content = []
    for li in sf.readlines():
        for st in loc_stat_types:
            if li.startswith(st):
                sf_content.append(li)
    sf.close()
    sf_content = force_unix_line_ends(sf_content)
    if sf_content == []:
        return True

    # compare stats
    if stats != sf_content:
        fcc_print(f"Stats read from {fea.base_name}.frd file")
        fcc_print("!=")
        fcc_print(f"Expected stats from {stat_file}")
        for i in range(len(stats)):
            if stats[i] != sf_content[i]:
                fcc_print(f"{stats[i].rstrip()} != {sf_content[i].rstrip()}")
        return True

    return False


def force_unix_line_ends(line_list):
    new_line_list = []
    for ln in line_list:
        if ln.endswith("\r\n"):
            ln = ln[:-2] + "\n"
        new_line_list.append(ln)
    return new_line_list


def collect_python_modules(femsubdir=None):
    if not femsubdir:
        pydir = join(FreeCAD.ConfigGet("AppHomePath"), "Mod", "Fem")
    else:
        pydir = join(FreeCAD.ConfigGet("AppHomePath"), "Mod", "Fem", femsubdir)
    collected_modules = []
    fcc_print(pydir)
    for pyfile in sorted(os.listdir(pydir)):
        if pyfile.endswith(".py") and not pyfile.startswith("Init"):
            if not femsubdir:
                collected_modules.append(os.path.splitext(os.path.basename(pyfile))[0])
            else:
                collected_modules.append(
                    femsubdir.replace("/", ".")
                    + "."
                    + os.path.splitext(os.path.basename(pyfile))[0]
                )
    return collected_modules


def all_test_files():
    # open all files
    cube_frequency()
    cube_static()
    Flow1D_thermomech()
    multimat()
    spine_thermomech()


# run the specific test case of the file
# open the file in FreeCAD GUI and return the doc identifier
def cube_frequency():
    testname = "femtest.testccxtools.TestCcxTools.test_3_freq_analysis"
    unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromName(testname))
    doc = FreeCAD.open(join(get_fem_test_tmp_dir(), "FEM_ccx_frequency", "cube_frequency.FCStd"))
    return doc


def cube_static():
    testname = "femtest.testccxtools.TestCcxTools.test_1_static_analysis"
    unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromName(testname))
    doc = FreeCAD.open(join(get_fem_test_tmp_dir(), "FEM_ccx_static", "cube_static.FCStd"))
    return doc


def Flow1D_thermomech():
    testname = "femtest.testccxtools.TestCcxTools.test_5_Flow1D_thermomech_analysis"
    unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromName(testname))
    doc = FreeCAD.open(
        join(get_fem_test_tmp_dir(), "FEM_ccx_Flow1D_thermomech", "Flow1D_thermomech.FCStd")
    )
    return doc


def multimat():
    testname = "femtest.testccxtools.TestCcxTools.test_2_static_multiple_material"
    unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromName(testname))
    doc = FreeCAD.open(join(get_fem_test_tmp_dir(), "FEM_ccx_multimat", "multimat.FCStd"))
    return doc


def spine_thermomech():
    testname = "femtest.testccxtools.TestCcxTools.test_4_thermomech_analysis"
    unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromName(testname))
    doc = FreeCAD.open(join(get_fem_test_tmp_dir(), "FEM_ccx_thermomech", "spine_thermomech.FCStd"))
    return doc
