#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
#    "biocypher<1.0.0,>=0.11.0",
#    "pooch<2.0.0,>=1.7.0",
#    "pandas<3.0.0,>=2.3.1",
#    "numpy<3.0.0,>=2.2.4",
#    "owlready2<1.0,>=0.49",
#    "jsonargparse<5.0,>=4.39",
#    "xdg-base-dirs<7.0.0,>=6.0.2",
#    "pandera[io]<1.0.0,>=0.27.0",
#    "alive-progress<4.0,>=3.2",
#    "fsspec<2026.0.0,>=2025.10.0",
#    "natsort>5.0.0",
#    "lxml>6.0.0",
#    "jmespath>=1.0.1",
# ]
# ///

""" The module holding utility functions for the main command line interface.
"""

import os
import sys
import yaml
import logging
import natsort
import pathlib
import platform
import xdg_base_dirs as xdg
import importlib
import networkx

import ontoweaver

# Additional error codes not handled by ontoweaver.exceptions.* classes.
error_codes = {
    "NetworkXError"   : 129,
    "Exception"       : 255,
}

def call_with_error_handling(func, *args, **kwargs):
    """Call the given function with the given argument,
    while system-exiting with the appropriate message and error code.
    """
    if "debug" in kwargs:
        debug = kwargs.pop("debug")

    if "section" in kwargs:
        sec = kwargs.pop("section")
        section = f" during {sec}"
    else:
        section = ""

    if debug:
        # In debug mode, let the exception ends the process and display the stack.
        return func(*args, **kwargs)
    else:
        # In non-debug mode, just display error messages and exit with error code.
        try:
            return func(*args, **kwargs)
        except ontoweaver.exceptions.OntoWeaverError as e:
            etype = type(e).__name__
            logging.error(f"{etype}{section} (in `ontoweave.{func.__name__}`)")
            edoc = type(e).__doc__
            if edoc:
                logging.error(edoc)
            logging.error(e)
            sys.exit(type(e).code)
        except networkx.exception.NetworkXError as e:
            logging.error(f"{type(e).__name__}{section} (in `ontoweave.{func.__name__}`).")
            logging.error(e)
            # probably "type not in the digraph"
            logging.error("Double check that you use the rdfs:label for this type in your schema, and not the IRI anchor, or look for any typo.")
            sys.exit(error_codes["NetworkXError"])
        except Exception as e:
            logging.error(f"UNKNOWN ERROR{section} (in `ontoweave.{func.__name__}`).")
            logging.error(e)
            sys.exit(error_codes["Exception"])


def check_file(filename):
    """Exit if the given filename does not exists or is not readable."""
    if not os.path.isfile(filename):
        logging.error(f"File `{filename}` not found.")
        # FIXME raise without exit if asked.debug
        sys.exit(ontoweaver.exceptions.FileError.code)

    if not os.access(filename, os.R_OK):
        logging.error(f"Cannot access file `{filename}`.")
        # FIXME raise without exit if asked.debug
        sys.exit(ontoweaver.exceptions.FileAccessError)


def config_directories(appname = "ontoweave"):
    """Yield standard configuration directories (as defined by XDG under MocOS/Unix)."""
    os = platform.system()
    logging.debug(f"Detected OS: {os}")

    #NOTE: All matched config files will be parsed and applied in the given order.
    if os == "Windows":
        if "APPDATA" in os.environ:
            yield pathlib.Path(os.environ["APPDATA"])
        yield pathlib.Path("~")/pathlib.Path("AppData")/pathlib.Path("Roaming")/pathlib.Path(appname)

    elif os == "Java" or os == "":
        logging.warning(f"I don't know where to search for configuration files on platform `{os}`, I'll only search in current directory")

    else: # Probably an Unix flavor (Darwin, Linux, Solaris, IRIX, etc.)
        # XDG will return the default defined in the specification
        # if the env variables are not set, so hardcoded defaults
        # like "~/.config/" should not be necessary.
        for p in xdg.xdg_config_dirs():
            yield p/appname
        yield xdg.xdg_config_home()/appname

    yield pathlib.Path("../src/ontoweaver")


def config_paths(appname = "ontoweave"):
    """Yield any path named <appname>.yaml in standard configuration directories."""
    dirs = config_directories()
    for p in dirs:
        yield str(p / (appname+".yaml"))


def main():
    import jsonargparse
    import argparse
    import subprocess
    import inspect

    appname = os.path.splitext(os.path.basename(sys.argv[0]))[0]

    logging.basicConfig()
    # logger = logging.getLogger(appname)
    logger = logging.getLogger("ontoweaver")

    config_files = list(config_paths(appname))

    do = jsonargparse.ArgumentParser(
        description = "A command line tool to run OntoWeaver mapping adapters on a set of tabular data, and call the created BioCypher export scripts.",
        epilog = f"Example usage:\n  {appname} table.csv:mapping.yaml\n  {appname} --biocypher-config config.yaml --auto-schema xschema.yaml table1.csv:mapping1.yaml table2.tsv:mapping1.yaml table3-part*.parquet:mapping2.yaml --import-script-run",
        default_config_files = config_files,
        env_prefix="ONTOWEAVE",
        default_env = True, # By default jsonargparse does not check environment, so one enables it explicitly.
        formatter_class = argparse.RawTextHelpFormatter,
        logger = logger, # FIXME jsonargparse seems to override any later config of logging, this seems like a bug.
    )

    do.add_argument("mapping", metavar="FILE:MAPPING", nargs="+",
        help=f"Run the given YAML MAPPING to extract data from the tabular FILE (usually a CSV). Several mappings can be passed to {appname}. You may also use the same mapping on different data files. If set to `STDIN`, will read the list of mappings from standard input.")

    do.add_argument("-c", "--config", metavar="FILE", action=jsonargparse.ActionConfigFile,
        help=f"The {appname} configuration file, which can host the same arguments than the command line tool. [default: {' or '.join(config_files)}]")

    do.add_argument("-C", "--biocypher-config", metavar="FILE", default="biocypher_config.yaml",
        help="The BioCypher config file (the one managing ontologies and the output backend). [default: %(default)s]")

    do.add_argument("-s", "--biocypher-schema", metavar="FILE", default="schema.yaml",
        help="The BioCypher schema file (the one managing node and edge types). [default: %(default)s]")

    do.add_argument("-m", "--auto-schema", metavar="FILE", default=None,
        help="Automatically generate a BioCypher schema from the mapping(s)," \
             " and save it into FILE." \
             " If an existing schema is passed with --biocypher-schema," \
             " then it is extended and saved into FILE." \
             " Raises an error if FILE already exists," \
             " unless --auto-schema-overwrite is passed. [default: %(default)s]")

    do.add_argument("-W", "--auto-schema-overwrite", action="store_true",
        help="Overwrite the schema generated with --auto-schema" \
             " if it exists [default: do not overwrite the auto schema]")

    do.add_argument("-p", "--parallel", metavar="NB_CORES", default="0",
        help=f"Number of processor cores to use when processing with multi-threading. `0` means a sequential processing (no parallelization, the default). Use 'auto' to let {appname} do its best to use a good number. [default: %(default)s]")

    do.add_argument("-i", "--import-script-run", action="store_true",
        help=f"If passed {appname} will call the import scripts created by Biocypher for you.")

    do.add_argument("-R", "--sort", metavar="KEY", default = "none",
        choices = ["none", "ascend"],
        help="Sort nodes and edges on their IDs. [default: %(default)s]")

    do.add_argument("-r", "--register", metavar="PYTHON_MODULE", nargs="*", default=[],
        help="Register all transformers available in the given module.")

    do.add_argument("-S", "--prop-sep", metavar="CHARACTER", default = ";",
        help="The character used to separate property values fused in the same property at the reconciliation step. [default: %(default)s]")

    do.add_argument("-a", "--type-affix", choices=["suffix","prefix","none"], default="none",
        help="Where to add the type string to the ID label. [default: %(default)s]")

    do.add_argument("-A", "--type-affix-sep", metavar="CHARACTER", default=":",
        help="Character used to separate the label from the type affix. [default: %(default)s]")

    do.add_argument("-E", "--pass-errors", action="store_true",
        help="When an error occurs, log is, and then try to continue processing. If not passed, the default behavior is to raise errors immediatly and stop execution.")

    do.add_argument("-l", "--log-level", default="WARNING",
        choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
        help="Configure the log level. [default: %(default)s]")

    do.add_argument("-v", "--validate-only", action="store_true",
                    help="Only validate the given input data, do not apply the mapping.")

    do.add_argument("-V", "--validate-output", action="store_true",
                    help="Validate the output data against the mapping rules.")

    do.add_argument("-e", "--pandas-sep", metavar="CHARACTER",
        help="Character used by the Pandas module to separate values in the input file database. [default: guessed from the extension]")

    do.add_argument("-D", "--debug", action="store_true",
        help="Run in debug mode. implies `--log-level DEBUG`, disables `--pass-errors`. NOTE: this will disable explicit return codes and show the call stack.")

    asked = do.parse_args()

    if asked.debug:
        # if asked.log_level != "DEBUG":
        #     logger.setLevel("DEBUG")
        #     logger.warning(f"You asked for --debug but set --log-level={asked.log_level}, I will ignore that and set --log-level=DEBUG")
        if asked.pass_errors:
            logger.warning("You asked for --debug but passed --pass-errors, I will ignore that.")
        # asked.log_level = "DEBUG"
        asked.pass_errors = False

    logger.setLevel(asked.log_level)
    logging.getLogger("ontoweaver").setLevel(asked.log_level)

    logger.info("OntoWeave parameters:")

    logger.info(f"    config files: {config_files}")
    logger.info(f"    config: `{asked.biocypher_config}`")
    logger.info(f"    schema: `{asked.biocypher_schema}`")
    logger.info(f"    auto-schema: `{asked.auto_schema}`")
    logger.info(f"    parallel: `{asked.parallel}`")
    logger.info(f"    prop-sep: `{asked.prop_sep}`")
    logger.info(f"    type-affix: `{asked.type_affix}`")
    logger.info(f"    type-affix-sep: `{asked.type_affix_sep}`")
    logger.info(f"    pandas-sep: `{asked.pandas_sep}`")
    logger.info(f"    import-script-run: `{asked.import_script_run}`")
    logger.info(f"    sort: `{asked.sort}`")
    logger.info(f"    register: `{asked.register}`")
    logger.info(f"    debug: `{asked.debug}`")
    logger.info(f"    log-level: `{asked.log_level}`")
    logger.info(f"    pass-errors: `{asked.pass_errors}`")
    logger.info(f"    validate-only: `{asked.validate_only}`")
    logger.info(f"    validate-output: `{asked.validate_output}`")

    logger.info(f"    asked mappings: `{asked.mapping}`")
    asked_mapping = []

    if asked.mapping == ["STDIN"]:
        while True:
            try:
                item = sys.stdin.readline()
            except UnicodeDecodeError:
                continue
            except KeyboardInterrupt:
                break
            if not item:
                break
            asked_mapping.append(item)
    else:
        asked_mapping = asked.mapping

    logger.info("    parsed mappings:")
    mappings = {}
    for data_map in asked_mapping:
        if ":" not in data_map:
            msg = f"Cannot parse the DATA:MAPPING `{data_map}`, I cannot find the colon character."
            logger.error(msg)
            sys.exit(ontoweaver.exceptions.ConfigError.code)
        data,map = data_map.split(":")
        mappings[data] = map
        logger.info(f"    `{data}` => `{map}`")

    if asked.parallel == "auto":
        parallel = min(32, (os.process_cpu_count() or 1) + 4)
    else:
        parallel = int(asked.parallel)
    logger.info(f"    parallel: `{asked.parallel}`")

    # Late import to avoid useless Biocypher's logs when asking for --help.
    from biocypher._logger import get_logger as biocypher_logger
    biocypher_logger("biocypher").setLevel(asked.log_level)

    # Validate the input data if asked.
    if asked.validate_only:
        logger.info("Validating input data frame...")
        call_with_error_handling(
            # function
            ontoweaver.validate_input_data,
            # its arguments
            filename_to_mapping = mappings,
            sep = asked.pandas_sep,
            raise_errors = not asked.pass_errors,
            # Error handling parameters
            debug = asked.debug,
            section = "validation of input data"
        )

    # Register all transformers existing in the given modules.
    ontoweaver.transformer.register_all( asked.register )

    # Double check file inputs and exit on according errors.
    check_file(asked.biocypher_config)

    # --auto-schema will generate this file instead of reading it.
    if not asked.auto_schema:
        check_file(asked.biocypher_schema)
    else:
        try:
            with open(asked.biocypher_schema) as fd:
                existing_schema = yaml.safe_load(fd)
        except Exception as e:
            logger.debug(e)
            msg = "There is no valid existing BioCypher schema," \
                  " I will create a completely new one."
            logger.info(msg)
            existing_schema = {}
        else:
            logger.info(f"Loaded the existing schema `{asked.biocypher_schema}`," \
                         " I will now extend it.")

        logger.info("Validating input data frame...")
        extended_schema_filename = call_with_error_handling(
            # function
            ontoweaver.autoschema,
            # its arguments
            asked.mapping,
            existing_schema,
            asked.auto_schema,
            asked.auto_schema_overwrite,
            not asked.pass_errors,
            # Error handling parameters
            debug = asked.debug,
            section = "validation of input data"
        )

        # Replace the schema filename with the autoschema one.
        logger.debug(f"Extend the original schema `{asked.biocypher_schema}` into `{extended_schema_filename}`")
        asked.biocypher_schema = extended_schema_filename

    for file_map in asked.mapping:
        data_file, map_file = file_map.split(":")
        if '*' not in data_file and '?' not in data_file:
            # Do not check if we are globbing several data files.
            check_file(data_file)

        if map_file != "automap":
            check_file(map_file)

    if asked.validate_output:
        validate_output = True
    else:
        validate_output = False

    kw = {}
    if asked.pandas_sep:
        kw = {"sep": asked.pandas_sep}

    if asked.sort == "none":
        sort_key = None
    elif asked.sort == "ascend":
        sort_key = natsort.natsort_keygen()
    else:
        msg = f"Unsupported sorting type `{asked.sort}`"
        logging.error(msg)
        sys.exit("ConfigError")

    logger.info(f"Running OntoWeaver...")
    import_file = call_with_error_handling(
        # function
        ontoweaver.weave,
        # its arguments
        asked.biocypher_config,
        asked.biocypher_schema,
        mappings,
        parallel_mapping = parallel,
        reconciliate_sep = asked.prop_sep,
        affix = asked.type_affix,
        type_affix_sep = asked.type_affix_sep,
        validate_output = validate_output,
        sort_key = sort_key,
        raise_errors = not asked.pass_errors,
        **kw,
        # Error handling parameters
        debug = asked.debug,
        section = "weaving"
    )

    # Output import file on stdout, in case the user would want to capture it.
    print(import_file)
    check_file(import_file)

    if asked.import_script_run:
        shell = os.environ["SHELL"]
        logger.info(f"Run import scripts with {shell}...")
        if asked.debug:
            subprocess.run([shell, import_file])

        else:
            try:
                subprocess.run([shell, import_file])
            except Exception as e:
                logger.error(e)
                sys.exit(ontoweaver.exceptions.SubprocessError.code)

    logger.info("Done")

if __name__ == "__main__":
    main()
