#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright 2024-2025 Max-Planck-Institut für Nachhaltige Materialien GmbH
# Copyright 2011-2024 Max-Planck-Institut für Eisenforschung GmbH
#
# DAMASK is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

import sys
import os
import re
import time
import tempfile
from optparse import OptionParser

import numpy as np

import damask

script_name = os.path.splitext(os.path.basename(__file__))[0]
script_id   = ' '.join([script_name,damask.version])

# Convert .mfd file into a usable format
# Broken into labeled sections (eg. nodes, links, etc)
# Each section has a list of labeled elements with formatted numerical data
def parseMFD(dat):
    formatted = []
    section = 0
    formatted.append({'label': 'header', 'uid': -1, 'els': []})
    # in between =beg= and =end= part of file
    in_block = False
    for line in dat:
        if in_block: # currently in a section
            # lines that start with a space are numerical data
            if line[0] == ' ':
                formatted[section]['els'].append([])

                # grab numbers
                nums = re.split(r'\s+', line.strip())

                for num in nums:
                    # floating point has format ' -x.xxxxxxxxxxxxe+yy'
                    # scientific notation is used for float
                    if (len(num) >= 4) and (num[-4] == 'e'):
                        formatted[section]['els'][-1].append(float(num))
                    else: # integer
                        formatted[section]['els'][-1].append(int(num))
            else: # not numerical data, so it is a label for an element or section end
                if line[0] == '=' and re.search(r'=end=$', line) is not None: # End of section, avoiding regex if possible
                    in_block = False
                else:
                    formatted[section]['els'].append([])
                    formatted[section]['els'][-1] = line

        else: # Not in a section, we are looking for a =beg= now
            search = re.search(r'=beg=\s+(\d+)\s\((.*?)\)', line)
            if search is not None: # found start of a new section
                section += 1
                in_block = True
                formatted.append({'label': search.group(2), 'uid': int(search.group(1)), 'els': []})
            else: # No =beg= found, probably in the header
                # Either header or somthing we didn't plan for - just save the line so it isn't lost
                if formatted[section]['uid'] > 0:
                    section += 1
                    formatted.append({'label': '', 'uid': -2, 'els': []}) # make dummy section to store unrecognized data
                formatted[section]['els'].append(line)

    return formatted

def asMFD(mfd_data):
    result = ''
    for section in mfd_data:
        if section['uid'] > 0:
            result += '=beg={0:5d} ({1})\n'.format(section['uid'], section['label'])
        for el in section['els']:
            if type(el) == str:
                result += el
            elif type(el) == list:
                for num in el:
                    if type(num) == int:
                        result += '{:20d}'.format(num)
                    elif type(num) == float:
                        result += '{:20.12e}'.format(num)
                    else:
                        print(f'WARNING: encountered unknown type: {type(el)}')
                result += '\n'
            else:
                print(f'WARNING: encountered unknown type: {type(el)}')
        if section['uid'] > 0:
            result += '=end=\n'
    return result.strip()


def add_servoLinks(mfd_data,active=[True,True,True]):  # directions on which to add PBC
    base = ['x','y','z']
    box = {'min': np.zeros(3,dtype='d'),
                  'max': np.zeros(3,dtype='d'),
              'delta': np.zeros(3,dtype='d'),
            }

    mfd_dict = {}
    for i in range(len(mfd_data)):
        mfd_dict[mfd_data[i]['label']] = i

    NodeCoords = np.array(mfd_data[mfd_dict['nodes']]['els'][1::4])[:,1:4]
    Nnodes = NodeCoords.shape[0]

    box['min'] = NodeCoords.min(axis=0)                                                             # find the bounding box
    box['max'] = NodeCoords.max(axis=0)
    box['delta'] = box['max']-box['min']
    for coord in range(3):                                                                          # calc the dimension of the bounding box
        if box['delta'][coord] != 0.0:
            for extremum in ['min','max']:
                rounded = round(box[extremum][coord]*1e+15/box['delta'][coord]) * \
                                1e-15*box['delta'][coord]                                           # rounding to 1e-15 of dimension
                box[extremum][coord] = 0.0 if rounded == 0.0 else rounded                           # get rid of -0.0 (negative zeros)
    baseNode = {}
    linkNodes = []

#-------------------------------------------------------------------------------------------------
# loop over all nodes
    for node in range(Nnodes):
        key = {}
        maxFlag = [False, False, False]
        Nmax = 0
        Nmin = 0
        for coord in range(3):                                                                      # for each direction
            if box['delta'][coord] != 0.0:
                rounded = round(NodeCoords[node,coord]*1e+15/box['delta'][coord]) * \
                                1e-15*box['delta'][coord]                                           # rounding to 1e-15 of dimension
                NodeCoords[node,coord] = 0.0 if rounded == 0.0 else rounded                         # get rid of -0.0 (negative zeros)
            key[base[coord]] = "%.8e"%NodeCoords[node,coord]                                        # translate position to string
            if   (key[base[coord]] == "%.8e"%box['min'][coord]):                                    # compare to min of bounding box (i.e. is on outer face?)
                Nmin += 1                                                                           # count outer (back) face membership
            elif (key[base[coord]] == "%.8e"%box['max'][coord]):                                    # compare to max of bounding box (i.e. is on outer face?)
                Nmax += 1                                                                           # count outer (front) face membership
                maxFlag[coord] = True                                                               # remember face membership (for linked nodes)

        if Nmin > 0:                                                                                # node is on a back face
            # prepare for any non-existing entries in the data structure
            if key['x'] not in baseNode.keys():
                baseNode[key['x']] = {}
            if key['y'] not in baseNode[key['x']].keys():
                baseNode[key['x']][key['y']] = {}
            if key['z'] not in baseNode[key['x']][key['y']].keys():
                baseNode[key['x']][key['y']][key['z']] = 0

            baseNode[key['x']][key['y']][key['z']] = node+1                                         # remember the base node id

        if Nmax > 0 and Nmax >= Nmin:                                                               # node is on at least as many front than back faces
            if any([maxFlag[i] and active[i] for i in range(3)]):
                linkNodes.append({'id':node+1,'coord':NodeCoords[node],'faceMember':[maxFlag[i] and active[i] for i in range(3)]})

    mfd_data[mfd_dict['entities']]['els'][0][0] += len(linkNodes) * 3

    baseCorner = baseNode["%.8e"%box['min'][0]]["%.8e"%box['min'][1]]["%.8e"%box['min'][2]]         # detect ultimate base node

    links = {'uid': 1705, 'label': 'links', 'els': [[7,0],[9,0]]}
    linkID = 0
    for node in linkNodes:                                                                          # loop over all linked nodes
        linkCoord = [node['coord']]                                                                 # start list of control node coords with my coords
        for dir in range(3):                                                                        # check for each direction
            if node['faceMember'][dir]:                                                             # me on this front face
                linkCoord[0][dir] = box['min'][dir]                                                 # project me onto rear face along dir
                linkCoord.append(np.array(box['min']))                                              # append base corner
                linkCoord[-1][dir] = box['max'][dir]                                                # stretch it to corresponding control leg of "dir"

        nLinks = len(linkCoord)
        for dof in [1,2,3]:
            tied_node = node['id']
            nterms = 1 + nLinks

            linkID += 1
            # Link header
            links['els'].append('link{0}\n'.format(linkID))
            links['els'].append([linkID, 1])
            links['els'].append([0])
            links['els'].append([0])
            links['els'].append([0, 0, 0, tied_node])

            # these need to be put in groups of four
            link_payload = [dof, 0, nterms]

            # Individual node contributions (node, dof, coef.)
            for i in range(nterms):
                if i == nLinks:
                    link_payload.append(baseCorner)
                else:
                    link_payload.append(baseNode["%.8e"%linkCoord[i][0]]["%.8e"%linkCoord[i][1]]["%.8e"%linkCoord[i][2]])
            for i in range(nterms):
                link_payload.append(dof)
            for i in range(nterms):
                if i == nLinks:
                    link_payload.append(1.0 - nLinks)
                else:
                    link_payload.append(1.0)

            # Needs to be formatted 4 data points per row, character width of 20, so 80 total
            for j in range(0, len(link_payload), 4):
                links['els'].append(link_payload[j:j+4])
            if j+4 < len(link_payload):
                links['els'].append(link_payload[j+4:])

    i = 0
    while i < len(mfd_data) and mfd_data[i]['uid'] < 1705: i += 1

    if mfd_data[i]['uid'] == 1705: del mfd_data[i]
    mfd_data.insert(i, links)


#--------------------------------------------------------------------------------------------------
#                                MAIN
#--------------------------------------------------------------------------------------------------

parser = OptionParser(usage='%prog options [file[s]]', description = """
Set up servo linking to achieve periodic boundary conditions for a regular hexahedral mesh.
Use *py_connection to operate on model presently opened in MSC.Mentat.
""", version = script_id)

parser.add_option('-p', '--port',
                                    type = int, metavar = 'int', default = None,
                                    help = 'Mentat connection port')
parser.add_option('-x',
                                    action = 'store_false', default = True,
                                    help = 'no PBC along x direction')
parser.add_option('-y',
                                    action = 'store_false', default = True,
                                    help = 'no PBC along y direction')
parser.add_option('-z',
                                    action = 'store_false', default = True,
                                    help = 'no PBC along z direction')

(options, filenames) = parser.parse_args()

remote = options.port is not None

if remote and filenames != []:
    parser.error('file can not be specified when port is given.')
if filenames == []: filenames = [None]

if remote:
    sys.path.append(str(damask.solver.Marc().library_path))
    import py_mentat

    print(script_name+': waiting to connect...')
    filenames = [os.path.join(tempfile._get_default_tempdir(), next(tempfile._get_candidate_names()) + '.mfd')]
    try:
        py_mentat.py_connect('',options.port)
        py_mentat.py_send('*set_save_formatted on')
        py_mentat.py_send('*save_as_model "{}" yes'.format(filenames[0]))
        py_mentat.py_get_int("nnodes()")
    except py_mentat.InputError as err:
        print(f'{err}. Try Tools/Python/"Run as Separate Process" & "Initiate".')
        sys.exit(-1)
    print( 'connected...')

for name in filenames:
    while remote and not os.path.exists(name): time.sleep(0.5)
    with  open( name,'r') if name is not None else sys.stdin as file_in:
        print(script_name+': '+name)
        mfd = parseMFD(file_in)

    add_servoLinks(mfd,[options.x,options.y,options.z])
    with open( name,'w') if name is not None else sys.stdout as file_out:
        file_out.write(asMFD(mfd))

if remote:
    py_mentat.py_send('*open_model "{}"'.format(filenames[0]))
