"""This module is for reading ACE-format cross sections. ACE stands for "A
Compact ENDF" format and originated from work on MCNP_. It is used in a number
of other Monte Carlo particle transport codes.

ACE-format cross sections are typically generated from ENDF_ files through a
cross section processing program like NJOY_. The ENDF data consists of tabulated
thermal data, ENDF/B resonance parameters, distribution parameters in the
unresolved resonance region, and tabulated data in the fast region. After the
ENDF data has been reconstructed and Doppler-broadened, the ACER module
generates ACE-format cross sections.

.. _MCNP: https://laws.lanl.gov/vhosts/mcnp.lanl.gov/
.. _NJOY: http://t2.lanl.gov/codes.shtml
.. _ENDF: http://www.nndc.bnl.gov/endf

"""

import enum
from pathlib import Path
import struct

import numpy as np

import openmc.checkvalue as cv
from openmc.mixin import EqualityMixin
from .data import ATOMIC_SYMBOL, gnds_name, EV_PER_MEV, K_BOLTZMANN
from .endf import ENDF_FLOAT_RE


def get_metadata(zaid, metastable_scheme='nndc'):
    """Return basic identifying data for a nuclide with a given ZAID.

    Parameters
    ----------
    zaid : int
        ZAID (1000*Z + A) obtained from a library
    metastable_scheme : {'nndc', 'mcnp'}
        Determine how ZAID identifiers are to be interpreted in the case of
        a metastable nuclide. Because the normal ZAID (=1000*Z + A) does not
        encode metastable information, different conventions are used among
        different libraries. In MCNP libraries, the convention is to add 400
        for a metastable nuclide except for Am242m, for which 95242 is
        metastable and 95642 (or 1095242 in newer libraries) is the ground
        state. For NNDC libraries, ZAID is given as 1000*Z + A + 100*m.

    Returns
    -------
    name : str
        Name of the table
    element : str
        The atomic symbol of the isotope in the table; e.g., Zr.
    Z : int
        Number of protons in the nucleus
    mass_number : int
        Number of nucleons in the nucleus
    metastable : int
        Metastable state of the nucleus. A value of zero indicates ground state.

    """

    cv.check_type('zaid', zaid, int)
    cv.check_value('metastable_scheme', metastable_scheme, ['nndc', 'mcnp'])

    Z = zaid // 1000
    mass_number = zaid % 1000

    if metastable_scheme == 'mcnp':
        if zaid > 1000000:
            # New SZA format
            Z = Z % 1000
            if zaid == 1095242:
                metastable = 0
            else:
                metastable = zaid // 1000000
        else:
            if zaid == 95242:
                metastable = 1
            elif zaid == 95642:
                metastable = 0
            else:
                metastable = 1 if mass_number > 300 else 0
    elif metastable_scheme == 'nndc':
        metastable = 1 if mass_number > 300 else 0

    while mass_number > 3 * Z:
        mass_number -= 100

    # Determine name
    element = ATOMIC_SYMBOL[Z]
    name = gnds_name(Z, mass_number, metastable)

    return (name, element, Z, mass_number, metastable)


def ascii_to_binary(ascii_file, binary_file):
    """Convert an ACE file in ASCII format (type 1) to binary format (type 2).

    Parameters
    ----------
    ascii_file : str
        Filename of ASCII ACE file
    binary_file : str
        Filename of binary ACE file to be written

    """

    # Read data from ASCII file
    with open(str(ascii_file), 'r') as ascii_file:
        lines = ascii_file.readlines()

    # Set default record length
    record_length = 4096

    # Open binary file
    with open(str(binary_file), 'wb') as binary_file:
        idx = 0
        while idx < len(lines):
            # check if it's a > 2.0.0 version header
            if lines[idx].split()[0][1] == '.':
                if lines[idx + 1].split()[3] == '3':
                    idx = idx + 3
                else:
                    raise NotImplementedError('Only backwards compatible ACE'
                                              'headers currently supported')
            # Read/write header block
            hz = lines[idx][:10].encode()
            aw0 = float(lines[idx][10:22])
            tz = float(lines[idx][22:34])
            hd = lines[idx][35:45].encode()
            hk = lines[idx + 1][:70].encode()
            hm = lines[idx + 1][70:80].encode()
            binary_file.write(struct.pack(str('=10sdd10s70s10s'),
                              hz, aw0, tz, hd, hk, hm))

            # Read/write IZ/AW pairs
            data = ' '.join(lines[idx + 2:idx + 6]).split()
            iz = np.array(data[::2], dtype=int)
            aw = np.array(data[1::2], dtype=float)
            izaw = [item for sublist in zip(iz, aw) for item in sublist]
            binary_file.write(struct.pack(str('=' + 16*'id'), *izaw))

            # Read/write NXS and JXS arrays. Null bytes are added at the end so
            # that XSS will start at the second record
            nxs = [int(x) for x in ' '.join(lines[idx + 6:idx + 8]).split()]
            jxs = [int(x) for x in ' '.join(lines[idx + 8:idx + 12]).split()]
            binary_file.write(struct.pack(str('=16i32i{}x'.format(record_length - 500)),
                                          *(nxs + jxs)))

            # Read/write XSS array. Null bytes are added to form a complete record
            # at the end of the file
            n_lines = (nxs[0] + 3)//4
            start = idx + _ACE_HEADER_SIZE
            xss = np.fromstring(' '.join(lines[start:start + n_lines]), sep=' ')
            extra_bytes = record_length - ((len(xss)*8 - 1) % record_length + 1)
            binary_file.write(struct.pack(str('={}d{}x'.format(
                nxs[0], extra_bytes)), *xss))

            # Advance to next table in file
            idx += _ACE_HEADER_SIZE + n_lines


def get_table(filename, name=None):
    """Read a single table from an ACE file

    Parameters
    ----------
    filename : str
        Path of the ACE library to load table from
    name : str, optional
        Name of table to load, e.g. '92235.71c'

    Returns
    -------
    openmc.data.ace.Table
        ACE table with specified name. If no name is specified, the first table
        in the file is returned.

    """

    if name is None:
        return Library(filename).tables[0]
    else:
        lib = Library(filename, name)
        if lib.tables:
            return lib.tables[0]
        else:
            raise ValueError('Could not find ACE table with name: {}'
                             .format(name))


# The beginning of an ASCII ACE file consists of 12 lines that include the name,
# atomic weight ratio, iz/aw pairs, and the NXS and JXS arrays
_ACE_HEADER_SIZE = 12


class Library(EqualityMixin):
    """A Library objects represents an ACE-formatted file which may contain
    multiple tables with data.

    Parameters
    ----------
    filename : str
        Path of the ACE library file to load.
    table_names : None, str, or iterable, optional
        Tables from the file to read in.  If None, reads in all of the
        tables. If str, reads in only the single table of a matching name.
    verbose : bool, optional
        Determines whether output is printed to the stdout when reading a
        Library

    Attributes
    ----------
    tables : list
        List of :class:`Table` instances

    """

    def __init__(self, filename, table_names=None, verbose=False):
        if isinstance(table_names, str):
            table_names = [table_names]
        if table_names is not None:
            table_names = set(table_names)

        self.tables = []

        # Determine whether file is ASCII or binary
        filename = str(filename)
        try:
            fh = open(filename, 'rb')
            # Grab 10 lines of the library
            sb = b''.join([fh.readline() for i in range(10)])

            # Try to decode it with ascii
            sb.decode('ascii')

            # No exception so proceed with ASCII - reopen in non-binary
            fh.close()
            with open(filename, 'r') as fh:
                self._read_ascii(fh, table_names, verbose)
        except UnicodeDecodeError:
            fh.close()
            with open(filename, 'rb') as fh:
                self._read_binary(fh, table_names, verbose)

    def _read_binary(self, ace_file, table_names, verbose=False,
                     recl_length=4096, entries=512):
        """Read a binary (Type 2) ACE table.

        Parameters
        ----------
        ace_file : file
            Open ACE file
        table_names : None, str, or iterable
            Tables from the file to read in.  If None, reads in all of the
            tables. If str, reads in only the single table of a matching name.
        verbose : str, optional
            Whether to display what tables are being read. Defaults to False.
        recl_length : int, optional
            Fortran record length in binary file. Default value is 4096 bytes.
        entries : int, optional
            Number of entries per record. The default is 512 corresponding to a
            record length of 4096 bytes with double precision data.

        """

        while True:
            start_position = ace_file.tell()

            # Check for end-of-file
            if len(ace_file.read(1)) == 0:
                return
            ace_file.seek(start_position)

            # Read name, atomic mass ratio, temperature, date, comment, and
            # material
            name, atomic_weight_ratio, temperature, date, comment, mat = \
                struct.unpack(str('=10sdd10s70s10s'), ace_file.read(116))
            name = name.decode().strip()

            # Read ZAID/awr combinations
            data = struct.unpack(str('=' + 16*'id'), ace_file.read(192))
            pairs = list(zip(data[::2], data[1::2]))

            # Read NXS
            nxs = list(struct.unpack(str('=16i'), ace_file.read(64)))

            # Determine length of XSS and number of records
            length = nxs[0]
            n_records = (length + entries - 1)//entries

            # verify that we are supposed to read this table in
            if (table_names is not None) and (name not in table_names):
                ace_file.seek(start_position + recl_length*(n_records + 1))
                continue

            if verbose:
                kelvin = round(temperature * EV_PER_MEV / K_BOLTZMANN)
                print("Loading nuclide {} at {} K".format(name, kelvin))

            # Read JXS
            jxs = list(struct.unpack(str('=32i'), ace_file.read(128)))

            # Read XSS
            ace_file.seek(start_position + recl_length)
            xss = list(struct.unpack(str('={}d'.format(length)),
                                     ace_file.read(length*8)))

            # Insert zeros at beginning of NXS, JXS, and XSS arrays so that the
            # indexing will be the same as Fortran. This makes it easier to
            # follow the ACE format specification.
            nxs.insert(0, 0)
            nxs = np.array(nxs, dtype=int)

            jxs.insert(0, 0)
            jxs = np.array(jxs, dtype=int)

            xss.insert(0, 0.0)
            xss = np.array(xss)

            # Create ACE table with data read in
            table = Table(name, atomic_weight_ratio, temperature, pairs,
                          nxs, jxs, xss)
            self.tables.append(table)

            # Advance to next record
            ace_file.seek(start_position + recl_length*(n_records + 1))

    def _read_ascii(self, ace_file, table_names, verbose=False):
        """Read an ASCII (Type 1) ACE table.

        Parameters
        ----------
        ace_file : file
            Open ACE file
        table_names : None, str, or iterable
            Tables from the file to read in.  If None, reads in all of the
            tables. If str, reads in only the single table of a matching name.
        verbose : str, optional
            Whether to display what tables are being read. Defaults to False.

        """

        tables_seen = set()

        lines = [ace_file.readline() for i in range(_ACE_HEADER_SIZE + 1)]

        while len(lines) != 0 and lines[0].strip() != '':
            # Read name of table, atomic mass ratio, and temperature. If first
            # line is empty, we are at end of file

            # check if it's a 2.0 style header
            if lines[0].split()[0][1] == '.':
                words = lines[0].split()
                name = words[1]
                words = lines[1].split()
                atomic_weight_ratio = float(words[0])
                temperature = float(words[1])
                commentlines = int(words[3])
                for _ in range(commentlines):
                    lines.pop(0)
                    lines.append(ace_file.readline())
            else:
                words = lines[0].split()
                name = words[0]
                atomic_weight_ratio = float(words[1])
                temperature = float(words[2])

            datastr = ' '.join(lines[2:6]).split()
            pairs = list(zip(map(int, datastr[::2]),
                             map(float, datastr[1::2])))

            datastr = '0 ' + ' '.join(lines[6:8])
            nxs = np.fromstring(datastr, sep=' ', dtype=int)

            # Detemrine number of lines in the XSS array; each line consists of
            # four values
            n_lines = (nxs[1] + 3)//4

            # Ensure that we have more tables to read in
            if (table_names is not None) and (table_names <= tables_seen):
                break
            tables_seen.add(name)

            # verify that we are supposed to read this table in
            if (table_names is not None) and (name not in table_names):
                for _ in range(n_lines - 1):
                    ace_file.readline()
                lines = [ace_file.readline() for i in range(_ACE_HEADER_SIZE + 1)]
                continue

            # Read lines corresponding to this table
            lines += [ace_file.readline() for i in range(n_lines - 1)]

            if verbose:
                kelvin = round(temperature * EV_PER_MEV / K_BOLTZMANN)
                print("Loading nuclide {} at {} K".format(name, kelvin))

            # Insert zeros at beginning of NXS, JXS, and XSS arrays so that the
            # indexing will be the same as Fortran. This makes it easier to
            # follow the ACE format specification.
            datastr = '0 ' + ' '.join(lines[8:_ACE_HEADER_SIZE])
            jxs = np.fromstring(datastr, dtype=int, sep=' ')

            datastr = '0.0 ' + ''.join(lines[_ACE_HEADER_SIZE:_ACE_HEADER_SIZE + n_lines])
            xss = np.fromstring(datastr, sep=' ')

            # When NJOY writes an ACE file, any values less than 1e-100 actually
            # get written without the 'e'. Thus, what we do here is check
            # whether the xss array is of the right size (if a number like
            # 1.0-120 is encountered, np.fromstring won't capture any numbers
            # after it). If it's too short, then we apply the ENDF float regular
            # expression. We don't do this by default because it's expensive!
            if xss.size != nxs[1] + 1:
                datastr = ENDF_FLOAT_RE.sub(r'\1e\2\3', datastr)
                xss = np.fromstring(datastr, sep=' ')
                assert xss.size == nxs[1] + 1

            table = Table(name, atomic_weight_ratio, temperature, pairs,
                          nxs, jxs, xss)
            self.tables.append(table)

            # Read all data blocks
            lines = [ace_file.readline() for i in range(_ACE_HEADER_SIZE + 1)]


class TableType(enum.Enum):
    """Type of ACE data table."""
    NEUTRON_CONTINUOUS = 'c'
    NEUTRON_DISCRETE = 'd'
    THERMAL_SCATTERING = 't'
    DOSIMETRY = 'y'
    PHOTOATOMIC = 'p'
    PHOTONUCLEAR = 'u'
    PROTON = 'h'
    DEUTERON = 'o'
    TRITON = 'r'
    HELIUM3 = 's'
    ALPHA = 'a'

    @classmethod
    def from_suffix(cls, suffix):
        """Determine ACE table type from a suffix.

        Parameters
        ----------
        suffix : str
            Single letter ACE table designator, e.g., 'c'

        Returns
        -------
        TableType
            ACE table type

        """
        for member in cls:
            if suffix.endswith(member.value):
                return member
        raise ValueError("Suffix '{}' has no corresponding ACE table type."
                         .format(suffix))


class Table(EqualityMixin):
    """ACE cross section table

    Parameters
    ----------
    name : str
        ZAID identifier of the table, e.g. '92235.70c'.
    atomic_weight_ratio : float
        Atomic mass ratio of the target nuclide.
    temperature : float
        Temperature of the target nuclide in MeV.
    pairs : list of tuple
        16 pairs of ZAIDs and atomic weight ratios. Used for thermal scattering
        tables to indicate what isotopes scattering is applied to.
    nxs : numpy.ndarray
        Array that defines various lengths with in the table
    jxs : numpy.ndarray
        Array that gives locations in the ``xss`` array for various blocks of
        data
    xss : numpy.ndarray
        Raw data for the ACE table

    Attributes
    ----------
    data_type : TableType
        Type of the ACE data

    """
    def __init__(self, name, atomic_weight_ratio, temperature, pairs,
                 nxs, jxs, xss):
        self.name = name
        self.atomic_weight_ratio = atomic_weight_ratio
        self.temperature = temperature
        self.pairs = pairs
        self.nxs = nxs
        self.jxs = jxs
        self.xss = xss

    @property
    def zaid(self):
        return self.name.split('.')[0]

    @property
    def data_type(self):
        xs = self.name.split('.')[1]
        return TableType.from_suffix(xs[-1])

    def __repr__(self):
        return "<ACE Table: {}>".format(self.name)


def get_libraries_from_xsdir(path):
    """Determine paths to ACE files from an MCNP xsdir file.

    Parameters
    ----------
    path : str or path-like
        Path to xsdir file

    Returns
    -------
    list
        List of paths to ACE libraries
    """
    xsdir = Path(path)

    # Find 'directory' section
    with open(path, 'r') as fh:
        lines = fh.readlines()
    for index, line in enumerate(lines):
        if line.strip().lower() == 'directory':
            break
    else:
        raise RuntimeError("Could not find 'directory' section in MCNP xsdir file")

    # Handle continuation lines indicated by '+' at end of line
    lines = lines[index + 1:]
    continue_lines = [i for i, line in enumerate(lines)
                      if line.strip().endswith('+')]
    for i in reversed(continue_lines):
        lines[i] = lines[i].strip()[:-1] + lines.pop(i + 1)

    # Create list of ACE libraries -- we use an ordered dictionary while
    # building to get O(1) membership checks while retaining insertion order
    libraries = {}
    for line in lines:
        words = line.split()
        if len(words) < 3:
            continue

        lib = (xsdir.parent / words[2]).resolve()
        if lib not in libraries:
            # Value in dictionary is not used, so we just assign None. Below a
            # list is created from the keys alone
            libraries[lib] = None

    return list(libraries.keys())


def get_libraries_from_xsdata(path):
    """Determine paths to ACE files from a Serpent xsdata file.

    Parameters
    ----------
    path : str or path-like
        Path to xsdata file

    Returns
    -------
    list
        List of paths to ACE libraries
    """
    xsdata = Path(path)
    with open(xsdata, 'r') as xsdata_file:
        # As in get_libraries_from_xsdir, we use a dict for O(1) membership
        # check while retaining insertion order
        libraries = {}
        for line in xsdata_file:
            words = line.split()
            if len(words) >= 9:
                lib = (xsdata.parent / words[8]).resolve()
                if lib not in libraries:
                    libraries[lib] = None
    return list(libraries.keys())
