"""
@package pmsco.projects.demo.molecule
scattering calculation project for single molecules

the atomic positions are read from a molecule file.
cluster file, emitter (by chemical symbol), initial state and kinetic energy are specified on the command line.
there are no structural parameters.

@author Matthias Muntwiler, matthias.muntwiler@psi.ch

@copyright (c) 2015-20 by Paul Scherrer Institut @n
Licensed under the Apache License, Version 2.0 (the "License"); @n
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at
  http://www.apache.org/licenses/LICENSE-2.0
"""

import math
import numpy as np
import os.path
from pathlib import Path
import periodictable as pt
import argparse
import logging

# noinspection PyUnresolvedReferences
from pmsco.calculators.calculator import InternalAtomicCalculator
# noinspection PyUnresolvedReferences
from pmsco.calculators.edac import EdacCalculator
# noinspection PyUnresolvedReferences
from pmsco.calculators.phagen.runner import PhagenCalculator
import pmsco.cluster as cluster
from pmsco.data import calc_modfunc_loess
# noinspection PyUnresolvedReferences
import pmsco.elements.bindingenergy
from pmsco.helpers import BraceMessage as BMsg
import pmsco.project as project

logger = logging.getLogger(__name__)


class MoleculeFileCluster(cluster.ClusterGenerator):
    """
    cluster generator based on external file.

    work in progress.
    """
    def __init__(self, project):
        super(MoleculeFileCluster, self).__init__(project)
        self.base_cluster = None

    def load_base_cluster(self):
        """
        load and cache the project-defined coordinate file.

        the file path is set in self.project.cluster_file.
        the file must be in XYZ (.xyz) or PMSCO cluster (.clu) format (cf. pmsco.cluster module).

        @return: Cluster object (also referenced by self.base_cluster)
        """
        if self.base_cluster is None:
            clu = cluster.Cluster()
            clu.set_rmax(120.0)
            p = Path(self.project.cluster_file)
            ext = p.suffix
            if ext == ".xyz":
                fmt = cluster.FMT_XYZ
            elif ext == ".clu":
                fmt = cluster.FMT_PMSCO
            else:
                raise ValueError(f"unknown cluster file extension {ext}")
            clu.load_from_file(self.project.cluster_file, fmt=fmt)
            self.base_cluster = clu

        return self.base_cluster

    def count_emitters(self, model, index):
        """
        count the number of emitter configurations.

        the method creates the full cluster and counts the emitters.

        @param model: model parameters.
        @param index: scan and domain are used by the create_cluster() method,
            emit decides whether the method returns the number of configurations (-1),
            or the number of emitters in the specified configuration (>= 0).
        @return: number of emitter configurations.
        """
        clu = self.create_cluster(model, index)
        return clu.get_emitter_count()

    def create_cluster(self, model, index):
        """
        import a cluster from a coordinate file (XYZ format).

        the method does the following:
        - load the cluster file specified by self.cluster_file.
        - trim the cluster according to model['rmax'].
        - mark the 6 nitrogen atoms at the center of the trimer as emitters.

        @param model: rmax is the trim radius of the cluster in units of the surface lattice constant.

        @param index (named tuple CalcID) calculation index.
            this method uses the domain index to look up domain parameters in
            `pmsco.project.Project.domains`.
            `index.emit` selects whether a single-emitter (>= 0) or all-emitter cluster (== -1) is returned.

        @return pmsco.cluster.Cluster object
        """
        self.load_base_cluster()
        clu = cluster.Cluster()
        clu.copy_from(self.base_cluster)
        clu.comment = f"{self.__class__}, {index}"
        dom = self.project.domains[index.domain]

        # trim
        clu.set_rmax(model['rmax'])
        clu.trim_sphere(clu.rmax)

        # emitter selection
        idx_emit = np.where(clu.data['s'] == self.project.scans[index.scan].emitter)
        assert isinstance(idx_emit, tuple)
        idx_emit = idx_emit[0]
        if index.emit >= 0:
            idx_emit = idx_emit[index.emit]
        clu.data['e'][idx_emit] = 1

        # rotation
        if 'xrot' in model:
            clu.rotate_z(model['xrot'])
        elif 'xrot' in dom:
            clu.rotate_z(dom['xrot'])
        if 'yrot' in model:
            clu.rotate_z(model['yrot'])
        elif 'yrot' in dom:
            clu.rotate_z(dom['yrot'])
        if 'zrot' in model:
            clu.rotate_z(model['zrot'])
        elif 'zrot' in dom:
            clu.rotate_z(dom['zrot'])

        logger.info(f"cluster for calculation {index}: "
                    f"{clu.get_atom_count()} atoms, {clu.get_emitter_count()} emitters")

        return clu


class MoleculeProject(project.Project):
    """
    general molecule project.

    the following model parameters are used:

    @arg `model['zsurf']`   : position of surface above molecule (angstrom)
    @arg `model['Texp']`    : experimental temperature (K)
    @arg `model['Tdeb']`    : debye temperature (K)
    @arg `model['V0']`      : inner potential (eV)
    @arg `model['rmax']`    : cluster radius (angstrom)
    @arg `model['ares']`    : angular resolution (degrees, FWHM)
    @arg `model['distm']`   : dmax for EDAC (angstrom)

    the following domain parameters are used.
    they can also be specified as model parameters.

    @arg `'xrot'`           : rotation about x-axis (applied first) (deg)
    @arg `'yrot'`           : rotation about y-axis (applied after x) (deg)
    @arg `'zrot'`           : rotation about z-axis (applied after x and y) (deg)

    the project parameters are:

    @arg `cluster_file`    : name of cluster file of template molecule.
                              default: "dpdi-trimer.xyz"
    """
    def __init__(self):
        """
        initialize a project instance
        """
        super(MoleculeProject, self).__init__()
        self.model_space = project.ModelSpace()
        self.scan_dict = {}
        self.cluster_file = "demo-cluster.xyz"
        self.cluster_generator = MoleculeFileCluster(self)
        self.atomic_scattering_factory = PhagenCalculator
        self.multiple_scattering_factory = EdacCalculator
        self.phase_files = {}
        self.rme_files = {}
        self.modf_smth_ei = 0.5

    def create_params(self, model, index):
        """
        set a specific set of parameters given the optimizable parameters.

        @param model: (dict) optimization parameters
            this method requires zsurf, V0, Texp, Tdeb, ares and distm.

        @param index (named tuple CalcID) calculation index.
            this method formats the index into the comment line.
        """
        params = project.CalculatorParams()

        params.title = "molecule demo"
        params.comment = f"{self.__class__} {index}"
        params.cluster_file = ""
        params.output_file = ""
        initial_state = self.scans[index.scan].initial_state
        params.initial_state = initial_state
        emitter = self.scans[index.scan].emitter
        params.binding_energy = pt.elements.symbol(emitter).binding_energy[initial_state]
        params.polarization = "H"
        params.z_surface = model['zsurf']
        params.inner_potential = model['V0']
        params.work_function = 4.5
        params.polar_incidence_angle = 60.0
        params.azimuthal_incidence_angle = 0.0
        params.angular_resolution = model['ares']
        params.experiment_temperature = model['Texp']
        params.debye_temperature = model['Tdeb']
        params.phase_files = self.phase_files
        params.rme_files = self.rme_files
        # edac_interface only
        params.emitters = []
        params.lmax = 15
        params.dmax = model['distm']
        params.orders = [20]

        return params

    def create_model_space(self):
        """
        define the range of model parameters.

        see the class description for a list of parameters.
        """

        return self.model_space

    # noinspection PyUnusedLocal
    def calc_modulation(self, data, model):
        """
        calculate the modulation function with project-specific smoothing factor

        see @ref pmsco.pmsco.project.calc_modulation.

        @param data: (numpy.ndarray) experimental data in ETPI, or ETPAI format.

        @param model: (dict) model parameters of the calculation task. not used.

        @return copy of the data array with the modulation function in the 'i' column.
        """
        return calc_modfunc_loess(data, smth=self.modf_smth_ei)


def create_model_space(mode):
    """
    define the model space.
    """
    dom = project.ModelSpace()

    if mode == "single":
        dom.add_param('zsurf',   1.20)
        dom.add_param('Texp',  300.00)
        dom.add_param('Tdeb',  100.00)
        dom.add_param('V0',     10.00)
        dom.add_param('rmax',   50.00)
        dom.add_param('ares',    5.00)
        dom.add_param('distm',   5.00)
        dom.add_param('wdom1', 1.0)
        dom.add_param('wdom2', 1.0)
        dom.add_param('wdom3', 1.0)
        dom.add_param('wdom4', 1.0)
        dom.add_param('wdom5', 1.0)
    else:
        raise ValueError(f"undefined model space for {mode} optimization")

    return dom


def create_project():
    """
    create the project instance.
    """

    proj = MoleculeProject()
    proj_dir = os.path.dirname(os.path.abspath(__file__))
    proj.project_dir = proj_dir

    # scan dictionary
    # to select any number of scans, add their dictionary keys as scans option on the command line
    proj.scan_dict['empty'] = {'filename': os.path.join(proj_dir, "../common/empty-hemiscan.etpi"),
                               'emitter': "N", 'initial_state': "1s"}

    proj.mode = 'single'
    proj.model_space = create_model_space(proj.mode)
    proj.job_name = 'molecule0000'
    proj.description = 'molecule demo'

    return proj


def set_project_args(project, project_args):
    """
    set the project arguments.

    @param project: project instance

    @param project_args: (Namespace object) project arguments.
    """

    scans = []
    try:
        if project_args.scans:
            scans = project_args.scans
        else:
            logger.error("missing scan argument")
            exit(1)
    except AttributeError:
        logger.error("missing scan argument")
        exit(1)

    for scan_key in scans:
        scan_spec = project.scan_dict[scan_key]
        project.add_scan(**scan_spec)

    try:
        project.cluster_file = os.path.abspath(project_args.cluster_file)
        project.cluster_generator = MoleculeFileCluster(project)
    except (AttributeError, TypeError):
        logger.error("missing cluster-file argument")
        exit(1)

    try:
        if project_args.emitter:
            for scan in project.scans:
                scan.emitter = project_args.emitter
            logger.warning(f"override emitters of all scans to {project_args.emitter}")
    except AttributeError:
        pass

    try:
        if project_args.initial_state:
            for scan in project.scans:
                scan.initial_state = project_args.initial_state
            logger.warning(f"override initial states of all scans to {project_args.initial_state}")
    except AttributeError:
        pass

    try:
        if project_args.energy:
            for scan in project.scans:
                scan.energies = np.asarray((project_args.energy, ))
            logger.warning(f"override scan energy of all scans to {project_args.energy}")
    except AttributeError:
        pass

    try:
        if project_args.symmetry:
            for angle in np.linspace(0, 360, num=project_args.symmetry, endpoint=False):
                project.add_domain({'xrot': 0., 'yrot': 0., 'zrot': angle})
                logger.warning(f"override rotation symmetry to {project_args.symmetry}")
    except AttributeError:
        pass


def parse_project_args(_args):
    parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    # main arguments
    parser.add_argument('--scans', nargs="*",
                        help="nick names of scans to use in calculation (see create_project function)")
    parser.add_argument('--cluster-file',
                        help="path name of molecule file (xyz format).")

    # conditional arguments
    parser.add_argument('--emitter',
                        help="emitter: chemical symbol")
    parser.add_argument('--initial-state',
                        help="initial state term: e.g. 2p1/2")
    parser.add_argument('--energy', type=float,
                        help="kinetic energy (eV)")
    parser.add_argument('--symmetry', type=int, default=1,
                        help="n-fold rotational symmetry")

    parsed_args = parser.parse_args(_args)
    return parsed_args
