"""
Currently, we assume this code is universal for all versions of MOPAC
we support.  i.e. on adding a support for a future MOPAC release,
it should not be necessary to (substantially) modify this module.
Copyright Schrodinger, LLC. All rights reserved.
"""
# Contributors: Mike Beachy, Mark A. Watson
import os
import re
import shutil
import textwrap
from io import StringIO
import schrodinger.application.mopac.utils as utils
MOPAC_MODE = 'mopac'
TITLE_START = 'Input for structure '
TITLE_END = 'created by Schrodinger semiempirical NDDO python module.'
[docs]class StructureLauncherError(Exception):
    pass 
[docs]class StructureLauncher(object):
    """
    A class for running MOPAC calculations on a Structure object.
    Typically, this should not be instantiated manually, but instead
    used via the MopacAPI class using the get_launcher() method etc.
    """
    CHARGE = "CHARGE"
    MMOK = "MMOK"
    NOMM = "NOMM"
[docs]    def __init__(self,
                 mopac_launcher,
                 method,
                 minimize=True,
                 settings=None,
                 keywords=''):
        """
        :type mopac_launcher: MopacLauncher object
        :param mopac_launcher: API to MOPAC backend.
        :type method: module level constant
        :param method:
                The semi-empirical method to use for the calculation.
        :type minimize: bool
        :param minimize:
                If True, minimize the molecule, otherwise calculate a single point
                energy.
        :type settings: dict
        :param settings:
                A settings dictionary for MOPAC input settings. If a MOPAC
                keyword does not take a value, set the dictionary value to True.
        :type keywords: str
        :param keywords:
                A string of space-separated keywords to use directly in the
                MOPAC input file. Use of the settings argument is recommended
                over setting the keyword string directly.
        """
        # Index of the structure being written to the input file. The index is
        # included in the title so we can see which structures are missing from
        # output files
        self.structure_index = 1
        self.mopac_launcher = mopac_launcher
        if method not in self.mopac_launcher.valid_methods:
            raise RuntimeError("Unrecognized semi-empirical method: '%s'" %
                               method)
        self._settings = dict()
        self.method = method
        self.minimize = minimize
        self.keywords = keywords
        self.setKeyword(method, raise_conflicts=True)
        if not minimize:
            self.setKeyword('1SCF')
        if settings:
            for key, value in settings.items():
                self.setKeyword(key, value, raise_conflicts=True) 
[docs]    def setKeyword(self, key, value=True, raise_conflicts=False):
        """
        Set the MOPAC keyword to the provided value. If the keyword doesn't
        take a value, use True. The keyword will be stored as uppercase
        regardless of the argument case.
        :param raise_conflicts:
                If True, raise an exception when conflicting keywords are
                detected. Otherwise, have the current keyword take precedence.
        """
        if key == StructureLauncher.NOMM:
            if self.hasKeyword(StructureLauncher.MMOK):
                if raise_conflicts:
                    raise StructureLauncherError(
                        "MMOK and NOMM were both "
                        "specified, but are contradictory settings.")
                self.delKeyword(StructureLauncher.MMOK)
        elif key == StructureLauncher.MMOK:
            if self.hasKeyword(StructureLauncher.NOMM):
                if raise_conflicts:
                    raise StructureLauncherError(
                        "MMOK and NOMM were both "
                        "specified, but are contradictory settings.")
                self.delKeyword(StructureLauncher.NOMM)
        elif key in self.mopac_launcher.valid_methods:
            for m in self.mopac_launcher.valid_methods:
                if self.hasKeyword(m) and m != key:
                    if raise_conflicts:
                        raise StructureLauncherError(
                            "Multiple calculation "
                            "methods (%s and %s) have been specified." %
                            (key, m))
                    self.delKeyword(m)
        if self.hasKeyword(key):
            self.delKeyword(key)
        self._settings[key.upper()] = value 
[docs]    def getValue(self, key):
        """
        Get the value for the provided keyword, whether it was provided a
        key/value pair or in a MOPAC keywords string. Note that if the
        keyword and value are specified as part of the keywords string, the
        value is always returned as a string.
        If the keyword is specified but has no value, True is returned.
        Raise a KeyError if the keyword isn't defined.
        """
        ku = key.upper()
        if ku in self._settings:
            return self._settings[ku]
        # Look for a keyword optionally followed by an equals sign and a
        # value.
        keyword_re = re.compile(r"\b%s\b(\s*=\s*(?P<value>\S+))?" % key,
                                re.IGNORECASE)
        match = keyword_re.search(self.keywords)
        if match:
            if match.group('value'):
                return match.group('value')
            else:
                return True
        raise KeyError("Key '%s' not found." % ku) 
[docs]    def delKeyword(self, key):
        """
        Delete the keyword from the keywords specification and the kwdict
        dictionary.
        """
        # Look for a keyword optionally followed by an equals sign and a
        # value, and grab up any surrounding whitespace.
        keyword_re = re.compile(r"\s*\b%s\b(\s*=\s*\S+)?\s*" % key,
                                re.IGNORECASE)
        match = keyword_re.search(self.keywords)
        if match:
            # Remove the matching part, cleaning up spaces as we can.
            pre = self.keywords[:match.start()]
            post = self.keywords[match.end():]
            self.keywords = (pre + " " + post).strip()
        ku = key.upper()
        if ku in self._settings:
            del (self._settings[ku]) 
[docs]    def get_mopfile_text(self, structure):
        """
        Write a MOPAC input file to a StringIO buffer based on the current
        settings and the provided Structure object.
        :type structure: schrodinger.structure.Structure
        :param structure:
                The structure to use in writing the file.
        return StringIO buffer
        """
        settings = []
        for key, value in self._settings.items():
            if type(value) is bool:
                settings.append(key)
            else:
                settings.append("%s=%s" % (key, value))
        mopac_keywords = self.keywords
        added_settings = []
        if self.mopac_launcher.extra_keywords:
            added_settings.extend(self.mopac_launcher.extra_keywords)
        if not self.hasKeyword(StructureLauncher.NOMM):
            added_settings.append(StructureLauncher.MMOK)
        # If the structure has a formal charge, set the CHARGE keyword. If
        # the user has specified a CHARGE keyword, use it in preference to
        # the structure's formal charge. If the user-specified CHARGE is
        # different from the structure formal charge, warn about this mismatch.
        structure_charge = structure.formal_charge
        if structure_charge:
            if self.hasKeyword(StructureLauncher.CHARGE):
                charge = int(self.getValue(StructureLauncher.CHARGE))
                if charge != structure_charge:
                    print("WARNING: User-specified charge of %d does "
                          "not match the structure-determined charge "
                          "of %d." % (charge, structure_charge))
            else:
                added_settings.append(
                    "%s=%d" %
                    (StructureLauncher.CHARGE, structure.formal_charge))
        all_settings = "%s %s %s" % (mopac_keywords, " ".join(settings),
                                     " ".join(added_settings))
        buff = StringIO()
        # Wrap long lines. MOPAC has a 240 character input limit - three
        # lines of 80 chars each.
        lines = textwrap.wrap(all_settings, 77, break_long_words=False)
        # Add a "+" to all lines but the last one.
        for ix in range(len(lines) - 1):
            buff.write("%s +\n" % lines[ix])
        buff.write(lines[-1] + "\n")
        # Add structure index to the title line so we can know which structures
        # are missing from output files
        buff.write(TITLE_START + f"{self.structure_index}, " + TITLE_END + "\n")
        buff.write("\n")
        self.structure_index += 1
        opt = int(self.minimize)
        if 'GRADIENTS' in self.keywords:
            opt = 1
        for at in structure.atom:
            if at.element:
                buff.write("%s %13.6f %d %13.6f %d %13.6f %d\n" %
                           (at.element, at.x, opt, at.y, opt, at.z, opt))
            else:  # dummy atom
                buff.write("XX %13.6f %d %13.6f %d %13.6f %d\n" %
                           (at.x, opt, at.y, opt, at.z, opt))
        return buff 
[docs]    def write(self, structure, filename):
        """
        Write a MOPAC input file based on the current keyword argument
        settings and the provided Structure object.
        :type structure: schrodinger.structure.Structure
        :param structure: The structure to use in writing the file.
        :type filename: str
        :param filename: The name of the file to be written.
        """
        with open(filename, 'w') as fh:
            buff = self.get_mopfile_text(structure)
            shutil.copyfileobj(buff, fh)
        return 
[docs]    def hasKeyword(self, keyword):
        """
        If the given keyword was set in the constructor keywords string or
        as a key in the kwdict dictionary, return True, otherwise return
        False.
        """
        # Prepend a space so that we can search for " " + keyword. This
        # mirrors the way the MOPAC code looks for keywords.
        keywords = " " + self.keywords.upper()
        ku = keyword.upper()
        if keywords.find(" " + ku) != -1:
            return True
        if ku in self._settings:
            return True
        return False 
[docs]    def run(self,
            structure,
            jobname=None,
            input_file=False,
            tmpdir='',
            scratch_cleanup=utils.REMOVE,
            save_output_file=False):
        """
        Run a MOPAC calculation on the provided structure given the
        object's current settings.
        """
        # It seems like it should be possible to make the settings directly
        # into the MOPAC Fortran modules and avoid explicitly writing an input
        # file. Writing an input file is the expedient route for now.
        start_dir = os.getcwd()
        if not jobname:
            if structure.title:
                jobname = re.sub(r"\W", "", structure.title) + "_semi_emp"
            else:
                jobname = "semi_emp"
        inputfile = jobname + ".mop"
        scr_dir = utils.make_scratch_dir(tmpdir, jobname)
        external = None
        results = None
        try:
            try:
                external = os.path.abspath(self.getValue('EXTERNAL'))
                shutil.copy(external, scr_dir)
                self.setKeyword("EXTERNAL", os.path.basename(external))
            except KeyError:
                pass
            os.chdir(scr_dir)
            self.write(structure, inputfile)
            if input_file:
                shutil.copy(inputfile, start_dir)
            results = self.mopac_launcher.run(jobname, structure)
        finally:
            if external:
                # Restore original EXTERNAL value.
                self.setKeyword("EXTERNAL", external)
            utils.run_cleanup(results, start_dir, scr_dir, jobname,
                              save_output_file, scratch_cleanup)
        return results