"""
eMoleculesFunctions.py - functions for interacting with eMolecules

Use of this code is subject to the terms of the StarDrop License Agreement and
use, copying or redistribution of this file for use by other persons or
organisations is prohibited

Copyright(C) Optibrium Ltd 2026
support@optibrium.com
"""
import json
import os
import app
import traceback

import PythonWidgets
import QueryInterfaceWidgets
from script_logger import ScriptLogger
from eMoleculesWebServices import EMoleculeWebServices, SearchTypes

log = ScriptLogger('eMolecules.log')

CAS_NUMBER = "CAS Number"
CATALOG_NUMBER = "Catalog Number"
COMPOUND_NAME = "Compound Name"
VID = "VID"
SUPPLIER_NAME = "Supplier Name"
FIND_COMPOUND_TITLE = 'Find eMolecules Compounds'
FIND_COMPOUND_MESSAGE = 'Running eMolecules search web service'


def _serialise_search_params(search_params):
    """
    Serialise the provided search parameters to file so that they can be picked
    up again the next time we wish to search eMolecules. Pushing None to this
    function will simply truncate the file

    :param search_params:   The search params to write to file
    :return: None
    """
    params_file_path = "emolecules_search_params.txt"

    try:
        with open(params_file_path, "w") as f:
            if search_params:
                f.write(json.dumps(search_params))
    except OSError:
        traceback.print_exc()
        log.log("Failed to write search params to file")


def _read_search_params():
    params_file_path = "emolecules_search_params.txt"

    try:
        with open(params_file_path, "r") as f:
            params = f.read()
        if len(params) > 0:
            return json.loads(params)
        else:
            return None
    except OSError:
        traceback.print_exc()
        log.log("Failed to read search params from file")

    return None


def _serialise_auth_details(username, password, confirmed=False):
    """
    Serialise the provided authentication details to a emolecules_auth.txt file.
    The confirmed parameter determines whether there has been a successful run
    of the web services with these details yet. If yes then there is no further
    need to keep prompting for the details, however if there hasn't yet been
    one then we should continue to query until we get one which works.

    :param username:    The username to store in the auth file
    :param password:    The authentication code to store in the auth file
    :param confirmed:   Whether or not these details have been used successfully
                        or not. Default = False
    :type username: str
    :type password: str
    :type confirmed: bool
    :return: None
    """
    auth_file_path = "emolecules_auth.txt"

    auth_details = {
        "Username": username,
        "Password": password,
        "Confirmed": confirmed
    }

    auth_string = json.dumps(auth_details)

    try:
        with open(auth_file_path, "w") as f:
            f.write(auth_string)
    except OSError:
        traceback.print_exc()
        log.log("Failed to write authentication details to file")


def _request_login_details():
    """
    Query the user for a login and password combination we can use as details
    with the eMolecules API

    :return: A tuple formed from the username and password respectively
    """
    dlg = PythonWidgets.LogInDialog()
    if dlg.run():
        username = dlg.getUser()
        password = dlg.getPassword()

        _serialise_auth_details(username, password)

        return username, password
    else:
        return None, None


def _create_emolecules_web_services():
    """
    Create an instance of the EMoleculeWebServices object either based on user
    input for details or from the stored emolecules_auth.txt depending on
    whether stored details exist, and if they do whether they have been
    confirmed or not.

    If they have not been confirmed or do not exist then the user will be
    prompted to provide new details, otherwise we proceed with those in the
    stored file.

    This function may throw ValueError if after all this logic it still has no
    username or password - for example if there was no stored file and the user
    then cancelled the LogInDialog which popped up instead of providing details.

    :return: an instance of EMoleculeWebServices
    :rtype: EMoleculeWebServices
    """
    username = None
    password = None
    confirmed = False
    auth_file_path = "emolecules_auth.txt"

    if not os.path.isfile(auth_file_path):
        username, password = _request_login_details()
    else:
        try:
            with open(auth_file_path, "r") as f:
                file_contents = f.read()
                auth_details = json.loads(file_contents)
                if "Username" in auth_details:
                    username = auth_details["Username"]
                if "Password" in auth_details:
                    password = auth_details["Password"]
                if "Confirmed" in auth_details:
                    confirmed = auth_details["Confirmed"]

                if not confirmed:
                    username, password = _request_login_details()

        except ValueError:
            traceback.print_exc()
            log.log(
                "Failed to read %s as JSON - invalid format" % auth_file_path)
        except OSError:
            traceback.print_exc()
            log.log("Failed to open %s" % auth_file_path)

    if not username or not password:
        raise ValueError("Could not locate authentication details for "
                         "eMolecules API - cannot create link to web services")

    web_services = EMoleculeWebServices(username, password)
    return web_services


def _convert_structure_operator_to_search_type(structure_operator):
    """
    Convert a StarDrop structure operator (such as "IN" or "LIKE") into an
    equivalent eMolecules API SearchType value. This currently returns values
    for all StarDrop structure operators, even those which the eMolecules API
    does not support.

    Supported: "IN" and "EQ" (substructure and equals respectively)
    Unsupported: "NOTIN" and "LIKE"

    :param structure_operator:  The structure operator to convert
    :return: A SearchType corresponding to the operator provided
    :rtype: int
    """
    if structure_operator == "IN":
        return SearchTypes.substructure
    elif structure_operator == "NOTIN":
        # TODO: Resolve the fact that there isn't negated searching
        return SearchTypes.substructure
    elif structure_operator == "LIKE":
        # TODO: Resolve the fact that there isn't similarity searching
        return SearchTypes.substructure
    elif structure_operator == "EQ":
        return SearchTypes.exact


def _convert_option_string_to_search_option(search_option):
    """
    Convert a string representation of a search option to the value expected by
    the eMolecules SOAP API.

    :param search_option:  The search option to convert
    :return: A string value in the correct eMolecules API format
    :rtype: string
    """
    if search_option == CAS_NUMBER:
        return "cas"
    elif search_option == CATALOG_NUMBER:
        return "cat"
    elif search_option == COMPOUND_NAME:
        return "nm"
    elif search_option == VID:
        return "vid"


def _create_cont_entry(value, error):
    """
    Create a Cont entry - this function was taken almost entirely from
    DatasetCreator.py in the QueryTool

    :param value:   The value to store in the new cont entry
    :param error:   The error to store in the new cont entry
    :return:    A tuple representing a new cont entry which can be appended to a
                column
    """
    qual = '\x00'
    valid = True
    try:
        val = float(value)
    except ValueError:
        if value[0] == '<':
            qual = '\x01'
        elif value[0] == '>':
            qual = '\x02'
        try:
            val = float(value[1:])
        except ValueError:
            val = float(value[2:])
    try:
        err = float(error)
    except ValueError:
        err = 0.0
    return (
        12 + 2 * 8 + 1, (valid, [], []), (qual, val, err))


def _create_string_entry(value):
    """
    Create a string data entry

    :param value:   The string value to store in the new string entry
    :return:    The new string data entry
    """
    return app.Dataset.string(str(value))


def _create_columns(column_definitions):
    """
    Create columns for a dataset based on a set of column definitions. This
    is a convenience function that allows us to cut down on lots of repeated
    code to make columns.

    :param column_definitions:  The list of columns to create. Each column
                                should consist of a name, a type and None or a
                                list of category names depending on type
    :return: A tuple containing column headers and category dictionaries
    """
    cols = []
    category_dicts = []

    for column_definition in column_definitions:
        header, col_type, categories = column_definition

        if col_type == app.mol:
            cols.append(app.Dataset.meta(header, app.mol))
        elif col_type == app.cont:
            cols.append(app.Dataset.createNumberHeader(header))
        elif col_type == app.string:
            cols.append(app.Dataset.meta(header))
        elif col_type == app.cat:
            categories = set(categories)
            categories.discard('')
            header, cat_dict = app.Dataset.createCategoryHeader(header,
                                                                categories)
            cols.append(header)
            category_dicts.append(cat_dict)

    return cols, category_dicts


def emolsuppliers(appui):
    try:
        web_services = _create_emolecules_web_services()

        log.log("Retrieving eMolecule suppliers")
        dataset = None
        suppliers = web_services.emol_suppliers()

        if "supplierInfo" in suppliers:
            column_definitions = [
                (SUPPLIER_NAME, app.string, None),
                ("Status", app.cat, ["Preferred", "Nonpreferred"]),
            ]

            cols, category_dicts = _create_columns(column_definitions)
            category_dict = category_dicts[0]

            dataset = app.Dataset(cols)
            dataset.idColName = SUPPLIER_NAME

            for supplier in suppliers["supplierInfo"]:
                dataset.cols[0].append(
                    _create_string_entry(supplier.supplierName))
                if supplier.status == "preferred":
                    category_str = "Preferred"
                else:
                    category_str = "Nonpreferred"
                dataset.cols[1].append(app.Dataset.createCategoryEntry(
                    category_str, category_dict))

        if dataset:
            appui.exportDS("eMoleculeSuppliers", dataset.pack())
            _serialise_auth_details(web_services.client_software(),
                                    web_services.client_key(),
                                    True)
        else:
            warning_dlg = PythonWidgets.Warning()
            warning_dlg.setMessage(
                "Failed to retrieve eMolecule suppliers")
            warning_dlg.run()
            warning_dlg.delete()
    except Exception as e:
        traceback.print_exc()
        log.log(repr(e))


def _create_compound_dataset(compound_rows):
    column_definitions = [
        # Integer data but v. large numbers, if we could make this decimal with
        # 0 decimal places that would work as an app.cont entry
        (VID, app.string, None),
        ("SMILES", app.mol, None),
        ("MWT", app.cont, None),
        ("MF", app.string, None),
        ("Price", app.cont, None),
        ("Currency", app.string, None),
        ("Quantity", app.cont, None),
        ("Units", app.string, None),
        ("eMolecules URL", app.string, None),
        ("eMolecules Product URL", app.string, None),
        ("CAS", app.string, None),
        ("Chemical Names", app.string, None),
        (SUPPLIER_NAME, app.string, None),
        ("Supplier ID", app.string, None),
        ("MFCD", app.string, None),
        ("Tier", app.cont, None),
        ("Status", app.string, None),
        (CATALOG_NUMBER, app.string, None),
    ]

    cols, _ = _create_columns(column_definitions)

    dataset = app.Dataset(cols)
    dataset.idColName = VID

    for compound_row in compound_rows:
        url_string = '<a href="%(url)s">%(url)s</a>' % {
            'url': compound_row.moreInfoURL
        }

        if "supplierInfo" in compound_row.commercialInfo:
            for supplier in compound_row.commercialInfo["supplierInfo"]:
                product_url_string = '<a href="%(url)s">%(url)s</a>' % {
                    'url': supplier.extProductURL
                }

                if "pricePoints" in supplier:
                    for price_point in supplier["pricePoints"]:
                        dataset.cols[0].append(
                            _create_string_entry(compound_row.VID))
                        dataset.cols[1].append(
                            app.Dataset.molecule(compound_row.knownNames.SMILES))
                        dataset.cols[2].append(
                            _create_cont_entry(compound_row.molProperties.MWT,
                                               0.0))
                        dataset.cols[3].append(
                            _create_string_entry(compound_row.molProperties.MF))
                        dataset.cols[4].append(
                            _create_cont_entry(price_point.price, 0.0))
                        dataset.cols[5].append(
                            _create_string_entry(price_point.currency))
                        dataset.cols[6].append(
                            _create_cont_entry(price_point.quantity, 0.0))
                        dataset.cols[7].append(
                            _create_string_entry(price_point.units))
                        dataset.cols[8].append(_create_string_entry(url_string))
                        dataset.cols[9].append(
                            _create_string_entry(product_url_string))
                        if "CAS" in compound_row.knownNames:
                            cas_list = []
                            for cas in compound_row.knownNames.CAS:
                                cas_list.append(cas)
                            dataset.cols[10].append(
                                _create_string_entry(", ".join(cas_list)))
                        else:
                            dataset.cols[10].append(
                                app.Dataset.createInvalidEntry(app.string))
                        if "chemicalName" in compound_row.knownNames:
                            chemical_names_list = []
                            for chemical_name in compound_row.knownNames.chemicalName:
                                chemical_names_list.append(chemical_name)
                            dataset.cols[11].append(
                                _create_string_entry(
                                    ", ".join(chemical_names_list)))
                        else:
                            dataset.cols[11].append(
                                app.Dataset.createInvalidEntry(app.string))
                        dataset.cols[12].append(_create_string_entry(
                            supplier.supplierName))
                        dataset.cols[13].append(_create_string_entry(
                            supplier.supplierID))
                        dataset.cols[14].append(_create_string_entry(
                            supplier.MFCD))
                        dataset.cols[15].append(
                            _create_cont_entry(supplier.tier, 0.0))
                        dataset.cols[16].append(_create_string_entry(
                            supplier.status))
                        dataset.cols[17].append(_create_string_entry(
                            supplier.catalogNo))
                else:
                    dataset.cols[0].append(
                        _create_string_entry(compound_row.VID))
                    dataset.cols[1].append(
                        app.Dataset.molecule(compound_row.knownNames.SMILES))
                    dataset.cols[2].append(
                        _create_cont_entry(compound_row.molProperties.MWT,
                                           0.0))
                    dataset.cols[3].append(
                        _create_string_entry(compound_row.molProperties.MF))
                    dataset.cols[4].append(
                        app.Dataset.createInvalidEntry(app.cont))
                    dataset.cols[5].append(
                        app.Dataset.createInvalidEntry(app.string))
                    dataset.cols[6].append(
                        app.Dataset.createInvalidEntry(app.cont))
                    dataset.cols[7].append(
                        app.Dataset.createInvalidEntry(app.string))
                    dataset.cols[8].append(_create_string_entry(url_string))
                    dataset.cols[9].append(
                        _create_string_entry(product_url_string))
                    if "CAS" in compound_row.knownNames:
                        cas_list = []
                        for cas in compound_row.knownNames.CAS:
                            cas_list.append(cas)
                        dataset.cols[10].append(
                            _create_string_entry(", ".join(cas_list)))
                    else:
                        dataset.cols[10].append(
                            app.Dataset.createInvalidEntry(app.string))
                    if "chemicalName" in compound_row.knownNames:
                        chemical_names_list = []
                        for chemical_name in compound_row.knownNames.chemicalName:
                            chemical_names_list.append(chemical_name)
                        dataset.cols[11].append(
                            _create_string_entry(
                                ", ".join(chemical_names_list)))
                    else:
                        dataset.cols[11].append(
                            app.Dataset.createInvalidEntry(app.string))
                    dataset.cols[12].append(_create_string_entry(
                        supplier.supplierName))
                    dataset.cols[13].append(_create_string_entry(
                        supplier.supplierID))
                    dataset.cols[14].append(_create_string_entry(
                        supplier.MFCD))
                    dataset.cols[15].append(
                        _create_cont_entry(supplier.tier, 0.0))
                    dataset.cols[16].append(_create_string_entry(
                        supplier.status))
                    dataset.cols[17].append(_create_string_entry(
                        supplier.catalogNo))
        else:
            dataset.cols[0].append(
                _create_string_entry(compound_row.VID))
            dataset.cols[1].append(
                app.Dataset.molecule(compound_row.knownNames.SMILES))
            dataset.cols[2].append(
                _create_cont_entry(compound_row.molProperties.MWT,
                                   0.0))
            dataset.cols[3].append(
                _create_string_entry(compound_row.molProperties.MF))
            dataset.cols[4].append(
                app.Dataset.createInvalidEntry(app.cont))
            dataset.cols[5].append(
                app.Dataset.createInvalidEntry(app.string))
            dataset.cols[6].append(
                app.Dataset.createInvalidEntry(app.cont))
            dataset.cols[7].append(
                app.Dataset.createInvalidEntry(app.string))
            dataset.cols[8].append(_create_string_entry(url_string))
            dataset.cols[9].append(
                app.Dataset.createInvalidEntry(app.string))
            if "CAS" in compound_row.knownNames:
                cas_list = []
                for cas in compound_row.knownNames.CAS:
                    cas_list.append(cas)
                dataset.cols[10].append(
                    _create_string_entry(", ".join(cas_list)))
            else:
                dataset.cols[10].append(
                    app.Dataset.createInvalidEntry(app.string))
            if "chemicalName" in compound_row.knownNames:
                chemical_names_list = []
                for chemical_name in compound_row.knownNames.chemicalName:
                    chemical_names_list.append(chemical_name)
                dataset.cols[11].append(
                    _create_string_entry(
                        ", ".join(chemical_names_list)))
            else:
                dataset.cols[11].append(
                    app.Dataset.createInvalidEntry(app.string))
            dataset.cols[12].append(
                app.Dataset.createInvalidEntry(app.string))
            dataset.cols[13].append(
                app.Dataset.createInvalidEntry(app.string))
            dataset.cols[14].append(
                app.Dataset.createInvalidEntry(app.string))
            dataset.cols[15].append(
                app.Dataset.createInvalidEntry(app.cont))
            dataset.cols[16].append(
                app.Dataset.createInvalidEntry(app.string))
            dataset.cols[17].append(
                app.Dataset.createInvalidEntry(app.string))

    return dataset


def structure_query(appui):
    try:
        web_services = _create_emolecules_web_services()

        dlg = QueryInterfaceWidgets.StructureQueryDialog()
        # Restrict the search types to substructure and exact matching
        dlg.setPossibleMatchTypes(["SUBSTRUCTURE", "EXACT MATCH"])
        if dlg.run():
            query = dlg.selectedQuery()
            smiles, operator, _ = query[:3]
            log.log("Running structure query %s" % smiles)
            try:
                dataset = None
                search_type = _convert_structure_operator_to_search_type(
                    operator)
                prog_dlg = PythonWidgets.SimpleProgress()
                prog_dlg.modal = True
                prog_dlg.setWindowTitle(FIND_COMPOUND_TITLE)
                prog_dlg.setMessage(FIND_COMPOUND_MESSAGE)
                prog_dlg.show()
                appui.updateUI()

                molecules = web_services.query_search(smiles, search_type, "smi")
                prog_dlg.hide()
                prog_dlg.delete()

                if molecules and "compoundRows" in molecules:
                    _serialise_search_params(web_services.last_search_params())
                    dataset = _create_compound_dataset(
                        molecules["compoundRows"])

                if dataset:
                    search_params = web_services.last_search_params()
                    if search_params:
                        dataset_name = "Structure Search p1 of %(np)d" % {
                            'np': (web_services.last_search_params()['np'])
                        }
                        appui.exportDS(dataset_name, dataset.pack())
                    else:
                        appui.exportDS("Structure Search", dataset.pack())
                    _serialise_auth_details(web_services.client_software(),
                                            web_services.client_key(),
                                            True)
                else:
                    warning_dlg = PythonWidgets.Warning()
                    warning_dlg.setMessage(
                        "Failed to run structure query")
                    warning_dlg.run()
                    warning_dlg.delete()
            except Exception as e:
                log.log(repr(e))

        dlg.delete()
    except Exception as e:
        traceback.print_exc()
        log.log(repr(e))


def continue_search(appui):
    try:
        web_services = _create_emolecules_web_services()
        search_params = _read_search_params()

        if search_params:
            web_services.set_last_search_params(search_params)
            log.log("Continuing structure query %s" % str(search_params))
            try:
                dataset = None
                prog_dlg = PythonWidgets.SimpleProgress()
                prog_dlg.modal = True
                prog_dlg.setWindowTitle(FIND_COMPOUND_TITLE)
                prog_dlg.setMessage(FIND_COMPOUND_MESSAGE)
                prog_dlg.show()
                appui.updateUI()
                molecules = web_services.continue_search()
                prog_dlg.hide()
                prog_dlg.delete()

                if molecules and "compoundRows" in molecules:
                    _serialise_search_params(web_services.last_search_params())
                    dataset = _create_compound_dataset(
                        molecules["compoundRows"])

                if dataset:
                    dataset_name = "Structure Search p%(p)d of %(np)d" % {
                        'p': (search_params['p']),
                        'np': (search_params['np'])
                    }
                    appui.exportDS(dataset_name, dataset.pack())
                    _serialise_auth_details(web_services.client_software(),
                                            web_services.client_key(),
                                            True)
                else:
                    warning_dlg = PythonWidgets.Warning()
                    warning_dlg.setMessage(
                        "Failed to run structure query")
                    warning_dlg.run()
                    warning_dlg.delete()
            except Exception as e:
                traceback.print_exc()
                log.log(repr(e))
        else:
            warning_dlg = PythonWidgets.Warning()
            warning_dlg.setMessage(
                "There are currently no further results to display")
            warning_dlg.run()
            warning_dlg.delete()
    except Exception as e:
        traceback.print_exc()
        log.log(repr(e))


def text_query(appui):
    try:
        web_services = _create_emolecules_web_services()

        combo_select = PythonWidgets.ComboBoxSelector()
        combo_select.setText("Choose field to search in query")
        combo_select.setOptions("Search Method:",
                                [CAS_NUMBER, CATALOG_NUMBER, COMPOUND_NAME,
                                 VID])
        if combo_select.run():
            search_option = _convert_option_string_to_search_option(
                combo_select.getSelectedOption(0))
            log.log("Running text search on %s" % search_option)

            search_term = appui.query("Search eMolecules", "Enter search term",
                                      "")

            try:
                dataset = None
                prog_dlg = PythonWidgets.SimpleProgress()
                prog_dlg.modal = True
                prog_dlg.setWindowTitle(FIND_COMPOUND_TITLE)
                prog_dlg.setMessage(FIND_COMPOUND_MESSAGE)
                prog_dlg.show()
                appui.updateUI()

                molecules = web_services.query_search(search_term,
                                                      SearchTypes.substructure,
                                                      search_option)
                prog_dlg.hide()
                prog_dlg.delete()

                if molecules and "compoundRows" in molecules:
                    _serialise_search_params(web_services.last_search_params())
                    dataset = _create_compound_dataset(
                        molecules["compoundRows"])

                if dataset:
                    search_params = web_services.last_search_params()
                    if search_params:
                        dataset_name = "Structure Search p1 of %(np)d" % {
                            'np': (web_services.last_search_params()['np'])
                        }
                        appui.exportDS(dataset_name, dataset.pack())
                    else:
                        appui.exportDS("Structure Search", dataset.pack())
                    _serialise_auth_details(web_services.client_software(),
                                            web_services.client_key(),
                                            True)
                else:
                    warning_dlg = PythonWidgets.Warning()
                    warning_dlg.setMessage(
                        "Failed to run text query")
                    warning_dlg.run()
                    warning_dlg.delete()
            except Exception as e:
                traceback.print_exc()
                log.log(repr(e))

        combo_select.delete()
    except Exception as e:
        traceback.print_exc()
        log.log(repr(e))


def get_stardrop_definitions():
    definitions = [
        {
            'script_name': 'eMolecules/Structure Search...',
            'callback': structure_query
        },
        {
            'script_name': 'eMolecules/Text Search...',
            'callback': text_query
        },
        {
            'script_name': 'eMolecules/Continue Search',
            'callback': continue_search
        },
        {
            'script_name': 'eMolecules/eMolecules Suppliers',
            'callback': emolsuppliers
        }
    ]

    return definitions
