#!/usr/bin/env python

#
# Copyright (C) 2012 - 2016, 2020 - 2024
# Smithsonian Astrophysical Observatory
#
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301 USA.
#

"""Usage:

  convert_xspec_user_model <name> <model.dat>

    --help
    --clobber  or -c
    --verbose n  or -v n where n is 0, 1, 2, 3, 4, 5
    --prefix [p]  where p is the prefix for the user model names
    --version
    --copyright

The script should be run in the directory containing the source code;
it follows the XSPEC initpackage command and will compile files that
match

  Fortran: *.f *.f03 *.f90
  C:       *.c
  C++:     *.cxx *.C *.cc *.cpp

Unlike most CIAO tools and scripts this does not use the CIAO
parameter interface, instead it uses the standard UNIX command-line
paradigm.  Use --help for more information.

Requires:

  XSPEC 12.12.0 or later (so we can use the include files from the
  install directory - in earlier versions the install location was
  not usable as the diectory structure had been flattened).

Aim:

Convert an XSPEC user model (written in Fortran, C, or C++) into a
Python module that can be used by Sherpa.

It is based in part on information from the create_xspec_extension
script (provided as part of the Sherpa source distribution) and the
initpackage command from the XSPEC source distribution.

See
http://heasarc.gsfc.nasa.gov/xanadu/xspec/manual/XSappendixLocal.html
for a description of the model.dat format.

"""

toolname = "convert_xspec_user_model"
toolver  = "25 November 2024"

import sys
import os
import string
import subprocess as sbp
import time
import glob
import argparse
import importlib
import shutil
from pathlib import Path
import sysconfig
import tempfile

import numpy

import sherpa
import sherpa.astro.ui as ui
from sherpa.astro.utils import xspec
from sherpa.astro.xspec import get_xsversion

import ciao_contrib.logger_wrapper as lw

lgr = lw.initialize_logger(toolname)
v0 = lgr.verbose0
v1 = lgr.verbose1
v2 = lgr.verbose2
v3 = lgr.verbose3

valid_chars = string.ascii_letters + string.digits + '_'

help_str = f"""
Convert XSPEC user models into a form usable by Sherpa.

This is *experimental* code and has seen limited testing. It does not
support all possible XSPEC user models at this time; please contact
the CXC HelpDesk at https://cxc.harvard.edu/helpdesk/ if you are
unable to compile a model.

The required arguments are

  1) The name to use for the Python module name; this will be used
     in the 'import <module name>.ui' line to load the models into
     Sherpa. It should not match the name of one of the models.

  2) The XSPEC definition file for the model (or models), which
     is often called model.dat or lmodel.dat

Files that match

  Fortran: *.f *.f03 *.f90
  C:       *.c
  C++:     *.cxx *.C *.cc *cpp

are automatically compiled. The order of compilation is done
alphabetically, as this appears to be what XSPEC does. To include
other files on the link line - e.g. .o files - add there names on the
command line. Note that C++ files matching lpack_*.cxx and those that
end in FunctionMap.cxx are automatically excluded since XSPEC creates
files with these names; please contact the CXC HelpDesk if this is a
problem for your model!

So, if the model is defined in lmodel.dat and the source code is in
mdl1.f and mdl2.f then you could run this script as

  {toolname} mymodel lmodel.dat

and, once it has finished, you would say, within a Sherpa session,

  sherpa> import mymodel.ui

to load the models. Note that some models may require data files in
the working directory, or for you to set up environment variables.

If the models fail to compile then you can get more information by
running with '--verbose 2' (or higher).

"""

copyright_str = """
Copyright (C) 2012 - 2016, 2020 - 2024
Smithsonian Astrophysical Observatory

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 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 General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
02110-1301 USA.
"""


PYTHON_HEADER = """#
# This code is placed into the PUBLIC DOMAIN.
# Please contact the CXC Helpdesk if you have problems
# https://cxc.harvard.edu/helpdesk/
#
"""

CXX_HEADER = """//
// This code is placed into the PUBLIC DOMAIN.
// Please contact the CXC Helpdesk if you have problems
// https://cxc.harvard.edu/helpdesk/
//
"""


# The assumption is that by this point we know ASCDS_INSTALL must exist.
#
ASCDS_INSTALL = os.getenv("ASCDS_INSTALL")
ASCDS_INSTALL_PATH = Path(ASCDS_INSTALL)


def check_clobber(fname: Path) -> None:
    """Error out if we can't clobber the file."""

    if not fname.exists():
        return

    raise IOError(f"The file {fname} exists and --clobber is not set.")


def save(outpath: Path, txt: str) -> None:
    """Save the text to the file.

    Parameters
    ----------
    outfile : str
        The file name.
    txt : str
        The file contents.
    """

    v3(f"Creating {outpath}")
    outpath.write_text(txt, encoding="utf-8")


def find_share_xspecpath() -> Path:
    """Return the location of the share/xspec directory.

   The script returns the first match to the directory

      <script location>/../share/xspec
      <$ASCDS_INSTALL>/contrib/share/xspec

    It errors out if it can not be found.

    """

    # At present there's only one path looked for
    #
    v3("Looking for share/xspec/ in:")
    thispath = Path(os.path.dirname(__file__))
    for dname in ["../share/xspec"]:
        path = (thispath / dname).resolve()
        v3("  - {}".format(path))
        if path.is_dir():
            return path

    # I do not think we still have a ASCDS_INSTALL/contrib/ directory
    # but leave for now.
    #
    path = (ASCDS_INSTALL_PATH / "contrib/share/xspec").resolve()
    v3("  - {}".format(path))
    if path.is_dir():
        return path

    raise OSError("Internal error - unable to find share/xspec directory")


def find_xspec_basedir() -> Path:
    """Where are the XSPEC include files found?

    Check that in $ASCDS_INSTALL> there are

      - include/ and lib/ dirs
      - the file lib/libXS* present.

    We then return the path, and it is guaranteed to be an absolute
    path. So, the includes should be in /include and the libraries in
    /lib relative to this path.

    """

    # At present there's only one path looked for
    v3("Looking for XSPEC includes in:")
    path = ASCDS_INSTALL_PATH
    v3(f"  - {path}")
    if path.is_dir():
        # Should not need the resolve but leave in for now
        out = path.resolve()
    else:
        raise OSError(f"Unable to find the XSPEC directory: {ASCDS_INSTALL_PATH}")

    libdir = out / "lib"
    incdir = out / "include"
    for path in [libdir, incdir]:
        if not path.is_dir():
            raise OSError(f'Unable to find {path} or not a directory')

    # Perhaps this should be more explicit and look for
    # libXSFunctions/*, libXSUtil.*, libXS.*
    #
    matches = list(libdir.glob('libXS*'))
    if len(matches) == 0:
        raise OSError(f'Unable to find libXS* in {libdir}')

    return out


def validate_namefunc(namefunc):
    """Raise a ValueError if namefunc does not capitalize the return string."""
    inval = 'bob'
    outval = namefunc(inval)
    if not outval[0].isupper():
        raise ValueError(f"The namefunc routine does not capitalize the return value - e.g. '{inval}' -> '{outval}'.")


def add_prefix(prefix, inval):
    """Returns prefix prepended to inval (converting it to a string if
    necessary)."""
    return f"{prefix}{inval}"


def add_xsum_prefix(inval):
    """Returns XSUM prepended to inval (converting it to a string if
    necessary)."""
    return add_prefix("XSUM", inval)


def no_prefix(inval):
    """Returns inval converted to a string, after converting
    the first character to upper case.
    """
    return str(inval).capitalize()


def find_file_types(types):
    """Return a dictionary listing the types of files in the types
    dictionary, which has key as the type name and value as the glob
    pattern - e.g.

      types = { "generic": "*.f", "90": "*.f90" }

    Only those types that contain a match are included; if there are
    no matches then None is returned. The return names are sorted
    alphabetically.

    At present it turns out that it is possible to create the pattern
    from the typename, ie a dictionary is not needed as input, but
    leave as is as there is no need to "optimise" this.

    """

    out = {}
    for (typename, pattern) in types.items():
        match = glob.glob(pattern)
        if len(match) > 0:
            # It looks like XSPEC uses alphabetical sorting so do this
            # here (needed for reltrans to compile the F90 code in the
            # correct order).
            out[typename] = sorted(match)

    if len(out) == 0:
        return None

    return out


def find_fortran_files():
    """Return the Fortran files found in the current
    directory, labelled by "type".

      "f":   *.f
      "f03": *.f03
      "f90": *.f90

    The dictionary only contains keys if there was a
    match for that pattern; if there are no matches
    then None is returned.
    """

    return find_file_types({"f": "*.f",
                            "f03": "*.f03",
                            "f90": "*.f90"})


def find_c_files():
    """Return the C files found in the current
    directory, labelled by "type".

      "c": *.c

    The dictionary only contains keys if there was a
    match for that pattern; if there are no matches
    then None is returned.

    Note that on case-insensitive file systems, this
    will match the same files as find_c_files() for
    the "C" and "c" options.
    """

    return find_file_types({"c": "*.c"})


def is_fs_case_sensitive(path):
    """Is this filesystem case sensitive?

    This seems a lot of work.... It is taken from
    https://stackoverflow.com/a/36612604
    """

    # We force the filename to have mixed case by virtue of the
    # prefix.
    #
    with tempfile.NamedTemporaryFile(prefix='TmP',
                                     dir=path,
                                     delete=True) as tfile:
        return (not os.path.exists(tfile.name.lower()))


def find_cplusplus_files():
    """Return the Fortran files found in the current
    directory, labelled by "type".

      "cxx" : *.cxx
      "C"   : *.C
      "cc"  : *.cc
      "cpp" : *.cpp

    The dictionary only contains keys if there was a match for that
    pattern; if there are no matches then None is returned.

    Note that on case-insensitive file systems, this will match the
    same files as find_c_files() for the "C" and "c" options.

    Files that match the patterns

        lpack_*.cxx
        *FunctionMap.cxx

    are excluded from the searches, as they are typically created by
    XSPEC (unfortunately it depends on what name the user who called
    initpackage used to know what the '*' should be, so we just ignore
    any matches).

    """

    this_dir = os.getcwd()
    is_case_sen = is_fs_case_sensitive(this_dir)

    out = find_file_types({"cxx": "*.cxx",
                           "C": "*.C",
                           "cc": "*.cc",
                           "cpp": "*.cpp"})
    if out is None:
        return None

    if not is_case_sen and "C" in out:
        v1(f"WARNING: directory {this_dir} is  not case sensitive, so C++ may have found *.c files!")

    try:
        cxx = out["cxx"]
        repl = []
        for fname in cxx:
            if is_case_sen:
                check = fname.startswith("lpack_") or fname.endswith("FunctionMap.cxx")
            else:
                lname = fname.lower()
                check = lname.startswith("lpack_") or lname.endswith("functionmap.cxx")

            if check:
                v1(f"Skipping {fname} as assumed to have been created by initpackage")
                continue

            repl.append(fname)

        if len(repl) == 0:
            del out["cxx"]
        elif len(repl) != len(cxx):
            out["cxx"] = repl

    except KeyError:
        pass

    return out


def count_nfiles(label, fileinfo):
    """Display, at verbose=1, the number of files
    found for this file type (combining all the
    different types). At verbose=2 lists the files found.

    Returns the number.
    """

    if fileinfo is None:
        v1(f"Found no {label} files.")
        return 0

    ntot = 0
    for (k, vs) in fileinfo.items():
        n = len(vs)
        ntot += n
        if n == 1:
            v2(f"Found one *.{k} file")
        else:
            v2(f"Found {n} *.{k} files")
        for v in vs:
            v2(f"  {v}")

    v2("")
    if ntot == 1:
        v1(f"Found one {label} file.")
    else:
        v1(f"Found {ntot} {label} files.")

    return ntot


def create_xspec_init(outdir: Path, clobber: bool) -> None:
    """Create the xspec.inc file needed for using udmget.

    How often does this file change?

    """

    # For now we over-write the file as the assumption is that it
    # will be from a previous build.
    #
    outpath = outdir / "xspec.inc"
    if not clobber:
        check_clobber(outpath)

    save(outname, """

c Taken from XSPEC 12.13.0
c Common block for dynamic memory using udmget

      LOGICAL          MEMB(1)
      INTEGER*2        MEMS(1)
      INTEGER*4        MEMI(1)
      INTEGER*4        MEML(1)
      REAL             MEMR(1)
      DOUBLE PRECISION MEMD(1)
      COMPLEX          MEMX(1)
      CHARACTER(1)     MEMC(1)
      EQUIVALENCE (MEMB, MEMS, MEMI, MEML, MEMR, MEMD, MEMX, MEMC)
      COMMON /MEM/ MEMD
""")


def create_xspec_udmget(inpath: Path,
                        outpath: Path,
                        name: str,
                        clobber: bool
                        ) -> Path:
    """Copy over the code implemeting udmget.

    We also return the file name
    """

    udmfile = inpath / "install" / name
    if not udm.is_file():
        raise OSError(f"Internal error - unable to find {udmfile}")

    out = outpath / name
    if not clobber:
        check_clobber(out)

    shutil.copy(udmfile, out)
    return out


def build_meson(modname,
                langs,
                sources, fortransources, extrafiles,
                version,
                outpath: Path,
                udmget=False, udmget64=False,
                clobber=False,
                license='License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication'):
    """Create the Python/meson build files.

    Create: pyproject.toml, meson.build, and <outdir>/meson.build.

    Parameters
    ----------
    modname : str
        The module name.
    langs : list of str
        The languages (using XSPEC naming).
    sources : list of str
        The paths to the source files, excluding the FORTRAN files.
        It can be the empty list, but then fortransources must not be
        empty.
    fortransources : list of str
        The paths to the FORTRAN source files.  It can be the empty
        list, but then sources must not be empty.
    extrafiles : list of str
        Extra files. What are we to do with these?
    version : str
        The version number (dotted values).
    outpath : Path
        The base directory where to store the module. This must exist.
    udmget, udmget64 : bool, optional
        Is the udmget code to be included? At this point only one will
        be set (FORTRAN only).
    clobber : bool, optional
        Do we over-write existing files or error out if they exist.
    license : str, optional
        The license for the module.

    Notes
    -----
    A rather hacky way to build the XSPEC local model code, which is a
    collection of C++ and FORTRAN, and take advantage of the interface
    that the Sherpa XSPEC model uses (that handles the conversion from
    the user-supplied grid to the one that XSPEC needs).

    The Python packaging ecosystem wants the build to be somewhat
    hermetic, but here I want to make sure we use the same NumPy and
    Sherpa as is installed. That is, we do not make sherpa and numpy
    build requirements but just hard-code the paths into the meson
    build file.

    The build is done as if there is no "structure", relying on
    <outdir>/meson.build to copy the files into the correct part of
    the Python installation.

    """

    if extrafiles != []:
        raise ValueError("Unable to support extrafiles at this time")

    languages = set()
    for lang in langs:
        if lang == 'C style':
            languages.add('c')
        elif lang == 'C++ style':
            languages.add('cpp')
        elif lang.startswith('Fortran '):
            languages.add('fortran')
        else:
            raise ValueError(f"Unsupported language: {lang}")

    if len(languages) == 0:
        raise ValueError("No languages set!")

    # The interface code requires C++.
    #
    languages.add("cpp")
    languages_arg = ", ".join(f"'{l}'" for l in languages)

    modname_slashes = modname.replace('.', '/')

    basepath = outpath
    pyproject = Path("pyproject.toml")
    buildname1 = Path("meson.build")
    buildname2 = basepath / "meson.build"
    if not clobber:
        check_clobber(pyproject)
        check_clobber(buildname1)
        check_clobber(buildname2)

    # Copy over the source files. The srcnames values are relative
    # to basepath.
    #
    srcnames = []
    for f in sources:
        f = Path(f)
        v3(f" - checking source file {f}")
        if not f.is_file():
            raise OSError(f"Unable to find source name '{f}'")

        srcnames.append(f"../{f.name}")

    for f in fortransources:
        f = Path(f)
        v3(f" - checking FORTRAN file {f}")
        if not f.is_file():
            raise OSError(f"Unable to find FORTRAN file '{f}'")

        srcnames.append(f"../{f.name}")

    if len(srcnames) == 0:
        raise ValueError("Should not have got here as no source/FORTRAN files to process")

    # Copy over the udmget code, if needed.
    #
    if udmget or udmget64:

        v2(f"  udmget:           {udmget}")
        v2(f"  udmget64:         {udmget64}")

        xpath = find_share_xspecpath()

        # Create the xspec.inc file.
        create_xspec_init(basepath, clobber)

        # Copy over the udmget code.
        #
        if udmget:
            name = "xsudmget.cxx"
        else:
            name = "xsudmget64.cxx"

        udmpath = create_xspec_udmget(xpath, basepath, name, clobber)
        # srcnames.append(udmpath)
        srcnames.append(name)

    source_names = "\n".join([f"  '{n}'," for n in srcnames])

    # Where are the XSPEC include files we may need? These are
    # included in the contrib code.
    #
    xspec_basedir = find_xspec_basedir()

    # We send in the sherpa.get_include_path so that we do not need to
    # build and install a version of Sherpa that we then immediately
    # throw away. This may need to be re-evaluated if we ever need
    # more functionality from Sherpa.
    #
    # Ditto for NumPy
    #
    sherpa_include = sherpa.get_include()
    numpy_include = numpy.get_include()

    v2(f"  xspec basedir:    {xspec_basedir}")
    v2(f"  sherpa includes:  {sherpa_include}")
    v2(f"  NumPy includes:   {numpy_include}")

    # Let's find the version names of the XSPEC libraries:
    # hdsp_xxx, CCfits_xxx
    #
    # How clever should we be here?
    #
    if sysconfig.get_config_var("WITH_DYLD"):
        suffix = ".dylib"
    else:
        suffix = ".so"

    def find_libname(head):
        libdir = xspec_basedir / 'lib'
        if not libdir.is_dir():
            raise OSError(f"XSPEC lib dir not found or not a directory: {libdir}")

        # For now assume the library is always versioned.
        #
        match_glob = f"lib{head}_*{suffix}"
        matches = list(libdir.glob(match_glob))
        if len(matches) == 0:
            raise OSError(f"Unable to find {match_glob} in {libdir}")

        if len(matches) > 1:
            raise OSError(f"Multiple matches for {match_glob}")

        v2(f"   - found: {head:8s}  {matches[0]}")
        out = matches[0].stem
        return out[3:]  # drop the "lib" prefix

    hdsp_version = find_libname("hdsp")
    ccfits_version = find_libname("CCfits")

    largs = [f'-L{xspec_basedir}/lib',
             '-lXSFunctions',
             '-lXSUtil',
             '-lXS',
             f'-l{hdsp_version}',
             f'-l{ccfits_version}',
             '-lcfitsio',
            ]

    # Is this still needed?
    #
    if os.uname().sysname == 'Darwin':
        largs.append('-Wl,-no_compact_unwind')

    link_args = "\n".join(f"  '{n}'," for n in largs)

    date = time.asctime()

    # Create the pyproject.toml file. As mentioned, this really should
    # depend on numpy and sherpa, but for now we hard-code that
    # knowledge into the meson.file and assume they are both present.
    #
    # Note that CIAO 4.17 is only provided with Python 3.11 so the
    # list of supported Pythons can be short (for the classifier).
    #
    out = f'''[build-system]
requires = ["meson-python"]
build-backend = "mesonpy"

[project]
name = "{modname}"
version = "{version}"
description = "XSPEC user models in Sherpa: {modname}"
authors = [
 {{ name = "Smithsonian Astrophysical Observatory / Chandra X-Ray Center", email = 'cxchelp@cfa.harvard.edu' }}
]

classifiers = [
  '{license}',
  'Intended Audience :: Science/Research',
  'Programming Language :: Python :: 3.11',
  'Programming Language :: Python :: Implementation :: CPython',
  'Topic :: Scientific/Engineering :: Astronomy',
  'Topic :: Scientific/Engineering :: Physics',
  'Development Status :: 3 - Alpha'
]

requires_python = ">= 3.10"
dependencies = [
  "numpy",
  "sherpa"
]
'''
    save(pyproject, out)

    # There are two build.meson files needed. We could probably get
    # away with just one.
    #
    out = f"""# Auto-generated by convert_xspec_user_model
# Version: {toolver}
# Sherpa: {sherpa.__version__}
# Date: {date}

project('{modname}',
  [{languages_arg}],
  meson_version: '>= 1.1.0',
  version: '{version}'
)

py = import('python').find_installation(pure: false)

install_dir = py.get_install_dir()

incdir_numpy = '{numpy_include}'
incdir_sherpa = '{sherpa_include}'
incdirs_xspec = [
  '{xspec_basedir}/include',
  '{xspec_basedir}/include/XSFunctions',
  '{xspec_basedir}/include/XSFunctions/Utilities',
]

subdir('{basepath}')
"""

    save(buildname1, out)

    out = f"""# Set up the Python and compiled code

py_sources = [
  '__init__.py',
  'ui.py',
]

ext_sources = [
{source_names}
  '_models.cxx'
]

ext_include = [
  '.',
  incdir_numpy,
  incdir_sherpa
] + incdirs_xspec

# The list is based on
# - Appendix F: Using the XSPEC Models Library in Other Programs
#   https://heasarc.gsfc.nasa.gov/xanadu/xspec/manual/
# - looking at the link line created by XSPEC initpackage
#
link_args = [
{link_args}
]

py.install_sources(
  py_sources,
  subdir: '{modname_slashes}'
)

# The idea is that this all needs re-building if anything changes, so
# although it would be good to set up dependencies correctly it is not
# terrible if it is not done.
#
py.extension_module(
  '_models',
  ext_sources,
  subdir: '{modname_slashes}',
  include_directories: ext_include,
  link_args: link_args,
  install: true,
)

"""

    save(buildname2, out)



def build_module_ui(modname: str,
                    outpath: Path,
                    clobber: bool = False) -> None:
    """Create the ui module for the module."""

    outfile = outpath / "ui.py"
    if not clobber:
        check_clobber(outfile)

    out = PYTHON_HEADER
    out += f'''
"""
Provide access to the local models when using the Sherpa UI
layer.

The added models are reported to the user using Sherpa's logger
at the logging.INFO level. If the model exists in Sherpa's XSPEC
module then we skip it here.

"""

import logging

import sherpa.astro.ui
import sherpa.astro.xspec

import {modname} as _module

logger = logging.getLogger('sherpa')

# What models do we know about?
#
xsmodels = {{n for n in dir(sherpa.astro.xspec)
            if n.startswith('XS')}}

for name in _module.__all__:
    if name in xsmodels:
        logger.info(f"Skipping local model as clashes with XSPEC {{name.lower()}}")
        continue

    cls = getattr(_module, name)
    sherpa.astro.ui.add_model(cls)

    # Change the message based on the model type
    if issubclass(cls, sherpa.astro.xspec.XSAdditiveModel):
        mtype = "additive"
    elif issubclass(cls, sherpa.astro.xspec.XSConvolutionKernel):
        mtype = "convolution"
    elif issubclass(cls, sherpa.astro.xspec.XSMultiplicativeModel):
        mtype = "multiplicative"
    else:
        mtype = "unknown"

    msg = f"Adding {{mtype:14s}} XSPEC local model: {{name.lower()}}"
    logger.info(msg)
'''

    save(outfile, out)


def build_module_init(modname: str,
                      outpath: Path,
                      pycode, mdls,
                      modelfile,
                      infiles,
                      extrafiles,
                      version,
                      clobber: bool = False) -> None:
    """Create the module initalization code.

    Parameters
    ----------
    modname : str
        The Python module name.
    outpath : Path
        The output directory.
    mdls : sequence of ModelDefintion objects
        The models to process. We should not need to send this in.
    pycode : str
        The Python code representing the models, created by
        xspec.create_xspec_code.
    modelfile : str
        The name of the XSPEC model file.
    infiles : lst of str
        The model files.
    extrafiles : list of str
        ny extra files
    version : str
        The version number (dotted values).
    clobber : bool, optional
        Do we over-write existing files or error out if they exist.

    """

    outfile = outpath / "__init__.py"
    if not clobber:
        check_clobber(outfile)

    mdlnames = []
    atypes = []
    mtypes = []
    ctypes = []
    modeltypes = set()
    languages = set()
    for mdl in mdls:
        if mdl.modeltype == 'Add':
            modeltypes.add('XSAdditiveModel')
            ary = atypes
        elif mdl.modeltype == 'Con':
            modeltypes.add('XSConvolutionKernel')
            ary = ctypes
        elif mdl.modeltype == 'Mul':
            modeltypes.add('XSMultiplicativeModel')
            ary = mtypes
        else:
            raise ValueError(f"Unsupported model type {mdl.modeltype}")

        languages.add(mdl.language)
        mdlnames.append(f"'{mdl.clname}'")  # want to display strings im Python
        ary.append(mdl.clname.lower())

    if len(mdlnames) == 0:
        raise ValueError("Somehow got this far with no models to process!")

    if len(mdlnames) == 1:
        mdlnames = f"{mdlnames[0]},"
    else:
        mdlnames = ', '.join(mdlnames)

    xsnames = ", ".join(list(modeltypes))

    # Do we need to include Parameter (i.e. do we have a norm parameter)?
    #
    if 'XSAdditiveModel' in modeltypes:
        normpar = "Parameter, "
    else:
        normpar = ""

    # Do we need to import warnings? We could do this by checking what
    # condition leads to this (ie the model flags) but for now just check
    # if warnings.warn is used
    #
    use_warnings = pycode.find("warnings.warn") > -1
    warning_str = "import warnings" if use_warnings else ""

    out = PYTHON_HEADER
    out += f'''
"""Sherpa interfaces to XSPEC models."""

{warning_str}

from sherpa.models.parameter import {normpar}hugeval
from sherpa.astro.xspec import {xsnames}, XSParameter

from . import _models

__all__ = ({mdlnames})

# Provenance
#
'''

    # Note: the separation between "build info" and the rest is
    #       not clear
    out += "\n".join(["provenance = {",
                      f"  'date': '{time.asctime()}'",
                      f"  , 'buildinfo': {{ 'tool': '{toolname}', ",
                      f"'version': '{toolver}', ",
                      f"'sherpaversion': '{sherpa.__version__}', ",
                      f"'ASCDS_INSTALL': '{ASCDS_INSTALL}', ",
                      f"'machine': '{os.uname()[1]}' }}",
                      f"  , 'modulename': '{modname}'",
                      f"  , 'moduleversion': '{version}'",
                      f"  , 'modelfile': '{modelfile}'",
                      f"  , 'files': {infiles}",
                      f"  , 'extrafiles': {extrafiles}",
                      f"  , 'dir': '{os.getcwd()}'",
                      f"  , 'additive': {atypes}",
                      f"  , 'multiplicative': {mtypes}",
                      f"  , 'convolve': {ctypes}",
                      f"  , 'langs': {list(languages)}",
                      "}"])

    out += '''

# Class definitions
#
'''

    out += pycode
    save(outfile, out)


def build_module_cxx(modname,
                     outpath: Path,
                     cxxcode, mdls, clobber=False):
    """Create the C++ module code.

    Parameters
    ----------
    modname : str
        The Python module name.
    outpath : Path
        The output directory.
    cxxcode : str
        The C++ code created by xspec.create_xspec_code.
    mdls : sequence of ModelDefintion objects
        The models to process.
    clobber : bool, optional
        Do we over-write existing files or error out if they exist.

    """

    outfile = outpath / "_models.cxx"
    if not clobber:
        check_clobber(outfile)

    xspec_version_str = get_xsversion()
    v1(f"Using XSPEC version: {xspec_version_str}")

    out = CXX_HEADER
    out += cxxcode
    save(outfile, out)


def build_module(modname: str,
                 mdls,
                 modelfile,
                 infiles,
                 extrafiles,
                 version,
                 outpath: Path,
                 clobber: bool = False) -> None:
    """Create the module files.

    This does not copy over the source files as that is done by
    build_meson().

    Parameters
    ----------
    modname : str
        The Python module name.
    mdls : sequence of ModelDefintion objects
        The models to process.
    modelfile : str
        The name of the XSPEC model file.
    infiles : lst of str
        The model files.
    extrafiles : list of str
        ny extra files
    version : str
        The version number (dotted values).
    outpath : Path
        The base directory where to store the module.
    clobber : bool, optional
        Do we over-write existing files or error out if they exist.

    """

    xdata = xspec.create_xspec_code(mdls)

    build_module_ui(modname, outpath, clobber=clobber)
    build_module_init(modname, outpath, xdata.python, mdls,
                      modelfile, infiles, extrafiles, version,
                      clobber=clobber)
    build_module_cxx(modname, outpath,
                     xdata.compiled, mdls, clobber=clobber)


def compile_module(verbose=False):
    """Build the module via pyproject.toml.

    Parameters
    ----------
    verbose : bool, optional
        If set to True then report the build steps

    """

    checkfile = Path("pyproject.toml")
    if not checkfile.is_file():
        raise OSError(f"{checkfile} does not exist or is not a file!")

    args = ['pip', 'install']
    if verbose:
        args.append('--verbose')

    args.append('.')

    v3(f"Building with: {' '.join(args)}")
    rval = sbp.call(args)
    if rval != 0:
        raise ValueError(f"Unable to run {args}\n")


def check_unique_name(modulename):
    """Ensure that the modulename is "unique".

    We want to allow the user to re-use the same module name (e.g.
    when developing or updating code), so we use the provenance field
    in the module to check if this is the case.

    """

    v2(f"Checking if import {modulename} already exists")
    try:
        module = importlib.import_module(modulename)
    except ImportError:
        return

    try:
        known = module.provenance['buildinfo']['tool'] == toolname
        modver = module.provenance['moduleversion']
    except (AttributeError, KeyError):
        known = False

    if known:
        v2(f" - found version {modver}")
        return

    raise ValueError(f"The module name '{modulename}' appears to "
                     "already exist. Please use a different name.")


@lw.handle_ciao_errors(toolname, toolver)
def convert_xspec_user_model(modulename, modelfile,
                             udmget=False, udmget64=False,
                             extrafiles=None,
                             clobber=False,
                             version='1.0',
                             outdir='build_xspec_user_model',
                             namefunc=add_xsum_prefix,
                             verbose=False):
    """Create a Python module called modulename that allows the
    XSPEC user model - defined by the model file (e.g.
    modelfile='lmodel.dat') - using the code found in the following
    files in the directory:

      Fortran: *.f *.f03 *.f90
      C:       *.c
      C++:     *.cxx *.C *.cc *.cpp

    It creates (using the clobber to determine whether to continue if
    the files already exist):

        pyproject.toml
        meson.build
        <outdir>/__init__.py
        <outdir>/ui.py
        <outdir>/_models.cxx

    Parameters
    ----------
    modulename : str
        The Python module name. It is assumed to normally be a single
        term but it can include "." such as "xspec.galactic".
    modelfile : str
        The name of the XSPEC model file (e.g. "lmodel.dat").
    udmget, udmget64 : bool, optional
        Should the udmget or udmget64 code be included. This is only
        used if FORTRAN models are included and only one can be set.
    extrafiles : list of str or None, optional
        At the moment this is unused. The idea is to be able to send in
        extra files, but for the moment we require them to be in the
        local directory (so picked up automatically).
    clobber : bool, optional
        Do we over-write existing files or error out if they exist.
    version : str, optional
        The version number (dotted values). The default is '1.0'.
    outdir : str, optional
        The directory where all the code is copied and the Python
        configuration files added.
    namefunc : function reference, optional
        The function that is used to convert the XSPEC model name to the
        Sherpa model class name. The default is add_xsum_prefix but
        options are no_prefix and add_prefix, which would need to be
        partialy applied. The first character of the returned string
        must be in upper case.
    verbose : bool, optional
        Should the build stop report the steps? I may decide to change
        quite what this controls.

    """

    # Although extrafiles is sent in, at the moment we do not use it. This means
    # that we expect to use only files in the worknig directory.
    #
    if extrafiles is not None:
        raise NotImplementedError("extrafiles is not currently supported")

    extrafiles = []

    # These used to be v2 but I find them useful so make them v1.
    #
    v1(f"{toolname}: {toolver}")
    v1(f"  name:       {modulename}")
    v1(f"  modelfile:  {modelfile}")
    # v1(f"  extrafiles: {extrafiles}")
    v1(f"  clobber:    {clobber}")
    v1("")

    check_unique_name(modulename)

    # find source-code files; perhaps the cpp_files check should be
    # done before the c_files one to ensure that .c/.C files always
    # get treated as C++ on case-insensitive systems; not clear what
    # order XSPEC/initpackage does this since it depends on the
    # Makefile rules
    #
    fortran_files = find_fortran_files()
    c_files = find_c_files()
    cpp_files = find_cplusplus_files()
    n_fortran = count_nfiles("Fortran", fortran_files)
    n_c = count_nfiles("C", c_files)
    n_cpp = count_nfiles("C++", cpp_files)

    if sum([n_fortran, n_c, n_cpp]) == 0:
        raise IOError(f"No Fortran/C/C++ files found in {os.getcwd()}")

    # for now do not treat the different types differently; this may change
    allfiles = []
    for d in [fortran_files, c_files, cpp_files]:
        if d is None:
            continue

        for vs in d.values():
            allfiles.extend(vs)

    v3(f"Processing the following {len(allfiles)} source code file(s):")
    v3("  {}".format("\n  ".join(allfiles)))
    v3("")

    for f in extrafiles:
        if not os.path.exists(f):
            raise IOError(f"Unable to find file {f}")

    validate_namefunc(namefunc)

    # Unlike earlier versions of convert_xspec_user_model we do not
    # check compiled files when clobber is not set.
    #
    mdlinfo = xspec.parse_xspec_model_description(modelfile, namefunc=namefunc)

    # Temporarily force the fortran "function" name to lower case (this
    # should be done by sherpa.astro.utils.xspec but it's just one of
    # those things that I never noticed).
    #
    for mdl in mdlinfo:
        if not mdl.language.startswith("Fortran"):
            continue

        oname = mdl.funcname
        mdl.funcname = oname.lower()
        if mdl.funcname != oname:
            v2(f" - lower case conversion for Fortran model: {oname}")

    # Strip out models that call the same function. This is a somewhat
    # odd check, and could perhaps be done after other checks, but I
    # do it first. It could just ignore those models for which the
    # number of parameters is different, but as in that case it's
    # really just an alias for the model it doesn't seem worth it,
    # and better to skip the possible error.
    #
    # This was added to handle processing the XSPEC model file from
    # 12.8.2, which has the eplogpar model (2 params) calling the
    # logpar model - presumably by accident (bug report has been sent
    # to Keith) - which accepts 3 parameters. Since the wrapper code
    # includes an invariant on the number of parameters, this would
    # complicate things, so for now exclude them. This particular
    # model has since been fixed in XSPEC, but the check remains,
    # even though it is also part of the xspec utility library.
    #
    funcnames = {}
    for mdl in mdlinfo:
        try:
            funcnames[mdl.funcname] += 1
        except KeyError:
            funcnames[mdl.funcname] = 1

    invalidnames = [k for (k, v) in funcnames.items() if v > 1]
    if len(invalidnames) > 0:
        mdls = []
        for mdl in mdlinfo:
            if mdl.funcname in invalidnames:
                v1(f"Skipping model {mdl.name} as it calls " +
                   f"{mdl.funcname} which is used by " +
                   f"{funcnames[mdl.funcname]} different models")
            else:
                mdls.append(mdl)

        mdlinfo = mdls

    # Strip out unsupported models and check on the model names.
    #
    known_models = ui.list_models()
    # known_symbols = dir(ui)

    mdls = []
    mnames = []
    probs = []
    langs = set()
    for mdl in mdlinfo:
        v3(f" - checking model {mdl.name}")

        if mdl.modeltype in ['Mix', 'Acn']:
            v1(f"Skipping {mdl.name} as model type = {mdl.modeltype}")
            continue

        # The following check should never fire, but leave in
        if mdl.language not in ['Fortran - single precision',
                                'Fortran - double precision',  # un-tested
                                'C style', 'C++ style']:
            v1(f"Skipping {mdl.name} as language = {mdl.language}")
            continue

        # TODO: should there be a more-extensive naming scheme, and
        #       should this error out?
        lname = mdl.clname.lower()
        if lname == modulename:
            raise ValueError(f"model class {mdl.clname} has the " + \
                   "same name as the module, which is not allowed")

        if lname in known_models:
            pstr = f"model {mdl.name} has the same name as the existing model {lname}"
            v1("WARNING: " + pstr)
            probs.append(pstr)

        nflags = len(mdl.flags)
        if nflags > 0:
            v3(f" - at least one model flag; [0] = {mdl.flags[0]}")
            if mdl.flags[0] == 1:
                probs.append(f"model {mdl.name} calculates model variances; this is untested/unsupported in Sherpa")

            if nflags > 1 and mdl.flags[1] == 1:
                v3(f" - at least two model flags; [1] = {mdl.flags[1]}")
                probs.append(f"model {mdl.name} needs to be re-calculated per spectrum; this is untested.")

        langs.add(mdl.language)
        mdls.append(mdl)
        mnames.append(lname)

    nmdl = len(mdls)
    if nmdl == 0:
        raise ValueError("No supported models were found!")

    if nmdl == 1:
        v1("Processing one model.")
    else:
        v1(f"Processing {nmdl} models.")

    langs = sorted(list(langs))
    if len(langs) == 1:
        v1(f"Using language interface: {langs[0]}")
    else:
        v1(f"Using language interfaces: {', '.join(langs)}")

    srcfiles = []
    for d in [c_files, cpp_files]:
        if d is None:
            continue

        for vs in d.values():
            srcfiles.extend(vs)

    fortfiles = []
    for d in [fortran_files]:
        if d is None:
            continue

        for vs in d.values():
            fortfiles.extend(vs)

    # Validate the udmget settings. The actual logic gets handled later.
    #
    if len(fortfiles) > 0:
        if udmget and udmget64:
            raise ValueError("Only one of --udmget and --udmget64 can be used, not both.")

    elif udmget or udmget64:
        v1("The --udmget/--udmget64 option is ignored as there are no FORTRAN files")
        udmget = False
        udmget64 = False

    outpath = Path(outdir)
    if outpath.exists():
        if not outpath.is_dir():
            raise OSError(f"outdir={outdir} exists but is not a directory")
    else:
        v3(f" - creating {outpath}/")
        outpath.mkdir()

    build_meson(modulename,
                langs,
                srcfiles,
                fortfiles,
                extrafiles,
                version,
                outpath=outpath,
                udmget=udmget, udmget64=udmget64,
                clobber=clobber)
    build_module(modulename, mdls, modelfile, allfiles, extrafiles,
                 version,
                 outpath=outpath,
                 clobber=clobber)

    # Compile the code.
    #
    compile_module(verbose=verbose)

    # Test out whether we can import the model
    v1("")
    v1("Testing out importing the model ...")
    ierr = None
    try:
        v2(f"Trying to import {modulename}")
        importlib.import_module(modulename)
        v1("Import succeeded")

    except ImportError as exc:
        v0("")
        v0("Error: unable to import the module; possible reasons are:")
        v0("  - incompatible version of gcc/gfortran used.")
        v0("  - the model uses the udmget set of routines which are currently")
        v0("    unsupported")
        v0("  - missing a library or include directory")
        v0("")
        ierr = exc

    if ierr is None:
        v1("")
        v1("------------------------------------------------------------------")
        v1("")
        v1("Finished. You should be able to now say")
        v1(f"    import {modulename}.ui")
        v1("from Sherpa.")
        v1("")

        n = len(mnames)
        if n == 1:
            v1("The following model is available:")
        else:
            v1(f"The following {n} models are available:")

        v1("  {}".format("\n  ".join(mnames)))
        v1("")

    # report any problems
    if probs:
        n = len(probs)
        if n == 1:
            v1("Please note the following problem:")
        else:
            v1(f"Please note the following {n} problems:")

        v1("  {}".format("\n  ".join(probs)))
        v1("")

    if ierr is not None:
        raise ImportError(f"Unable to import model:\n{ierr}")


if __name__ == "__main__":

    parser = argparse.ArgumentParser(description=help_str,
                                     formatter_class=argparse.RawDescriptionHelpFormatter)

    parser.add_argument("name",
                        help="Name of Sherpa module")
    parser.add_argument("modelfile",
                        help="The XSPEC model definition file (eg lmodel.dat)")
    # parser.add_argument("extrafiles", nargs='*',
    #                     help="Additional files")

    parser.add_argument("--udmget", dest="udmget", action="store_true",
                        default=False,
                        help="Use the udmget routine (needed by some FORTRAN models)")
    parser.add_argument("--udmget64", dest="udmget64", action="store_true",
                        default=False,
                        help="Use the udmget64 routine (needed by some FORTRAN models)")

    # This is now ignored. Unfortunately argparse only added the
    # deprecated argument in Python 3.13.
    #
    parser.add_argument("--local", "-l",
                        dest="local", action="store_true", default=False,
                        help="Build the package locally rather than globally?; default is %(default)s")

    parser.add_argument("--prefix", "-p", dest="prefix", nargs='?',
                        default="XSUM", const=None,
                        help="Prefix for model names (empty or must start with a capital letter; default is %(default)s")

    parser.add_argument("--pyver", dest="pyver", type=str,
                        default="1.0",
                        help="The module version; default is %(default)s")

    parser.add_argument("--clobber", "-c", dest="clobber", action="store_true",
                        default=False,
                        help="Set to overwrite output files, otherwise script exits")
    parser.add_argument("--verbose", "-v", dest="verbose", type=int,
                        choices=range(0, 6), default=1,
                        help="Verbose level; higher for more screen output")

    parser.add_argument("--version", action="version",
                        version=toolver,
                        help="List the version date for the script and exit")
    parser.add_argument("--copyright", action="version",
                        version=copyright_str,
                        help="List the copyright for the script and exit")

    # support some development options
    arglist = lw.preprocess_arglist(sys.argv[1:])
    args = parser.parse_args(arglist)

    lw.set_verbosity(args.verbose)

    # Perhaps should do something else; eg namefunc=None means
    # use no_prefix (so that this can then be displayed at verbose=2
    # from within convert_xspec_user_model rather than here)?
    #
    if args.prefix is None:
        mkname = no_prefix
    else:
        if not args.prefix[0].isupper():
            # would like to make this appear the same as other errors
            # but this is outside the handle_ciao_errors wrapper.
            #
            sys.stderr.write(f"# {toolname} ({toolver}): " +
                             "ERROR the prefix argument must start with a " +
                             f"capital letter; sent {args.prefix}\n")
            sys.exit(1)

        def mkname(inval):
            return add_prefix(args.prefix, inval)

    # for some reason sys.tracebacklimit is 0, meaning no backtraces
    # when using --tracebackdebug/--debug
    sys.tracebacklimit = None

    # base verbose setting for the tool as input verbosity >= 2.
    verbose = args.verbose >= 2

    if args.local:
        has_color = sys.stdout.isatty() and os.getenv('NO_COLOR') is None
        if has_color:
            sys.stderr.write("\033[1;33m")

        sys.stderr.write("WARNING")
        if has_color:
            sys.stderr.write("\033[0;0m")

        sys.stderr.write(": --local flag is no-longer supported.\n")

    convert_xspec_user_model(args.name,
                             args.modelfile,
                             udmget=args.udmget,
                             udmget64=args.udmget64,
                             # extrafiles=args.extrafiles,
                             extrafiles=None,
                             clobber=args.clobber,
                             version=args.pyver,
                             namefunc=mkname,
                             verbose=verbose)

# End
