"""
eMoleculesWebServices.py - library for accessing eMolecules web services

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 certifi
import os
import ssl
import suds
import suds.transport.http
import urllib.request

# Note: the wsdl file has not been updated yet and still points to http:// paths
#       so using it is not possible. I have a locally modified version with the
#       correct https:// targets but ideally we would have an updated (correct)
#       definition at https://www.emolecules.com/
# WSDL_FILE_PATH = "https://www.emolecules.com/services/emolservicev3.wsdl"
_current_dir = os.path.dirname(os.path.realpath(__file__))
WSDL_FILE_PATH = "file:" + urllib.request.pathname2url(os.path.join(_current_dir, "emolservicev3.wsdl"))

class _PinnedCertHttpTransport(suds.transport.http.HttpTransport):
    """
    HttpTransport that forces verification against a bundled CA file instead
    of relying on the OS trust store / cache.
    """
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        try:
            self._ssl_context = ssl.create_default_context(cafile=certifi.where())
        except (ssl.SSLError, FileNotFoundError, OSError) as exc:
            print(f"Warning: failed to load pinned CA bundle, falling back "
                  f"to system defaults: {exc}")
            self._ssl_context = ssl.create_default_context()

    def u2handlers(self):
        handlers = super().u2handlers()
        handlers.append(urllib.request.HTTPSHandler(context=self._ssl_context))
        return handlers

class SearchTypes:
    """
    An "enum" class which defines several enumeration values and some static
    helper functions for use with this type
    """
    substructure, exact = list(range(1, 3))

    def __init__(self):
        """
        Defined for PEP8, this class should not be instantiated at any point
        """
        raise RuntimeError("Enum class SearchTypes instantiated")

    @staticmethod
    def is_search_type(val):
        """
        This function determines whether a particular value is compatible with a
        SearchTypes value.

        Usage:
        >>> val1 = 1 # Equivalent to SearchTypes.substructure
        >>> valid = SearchTypes.is_search_type(val1)
        >>> print valid # True
        >>> val3 = 9
        >>> valid = SearchTypes.is_search_type(val3)
        >>> print valid # False
        >>> val4 = list()
        >>> valid = SearchTypes.is_search_type(val4)
        >>> print valid # False

        :param val: The value to test to see if it is compatible with
                    SearchTypes
        :return: True if the value is compatible, false otherwise
        :rtype: Boolean
        """
        if val in range(1, 3):
            return True
        else:
            if isinstance(val, str):
                return True
            else:
                return False

    @staticmethod
    def to_string(search_type):
        """
        This function returns the string equivalent of the provided search type.
        Each numeric search type also has a string representation which can be
        used interchangeably. They are uniquely mappable on a one-to-one basis.

        Usage:
        >>> search_type = SearchTypes.substructure
        >>> search_type = SearchTypes.to_string(search_type)
        >>> print search_type # "ss"

        :param search_type: The search type to map to a string, this must be
                            valid for the function to succeed
        :return: The string representation of the provided debug level
        :rtype: str
        """

        if not SearchTypes.is_search_type(search_type):
            raise TypeError("The provided argument must be a search type")

        if isinstance(search_type, str):
            return search_type

        if search_type == SearchTypes.substructure:
            return "ss"
        elif search_type == SearchTypes.exact:
            return "ex"

        return "unknown"

    @staticmethod
    def from_string(search_type):
        """
        Convert from a string representation of a search type to the internal
        numeric representation of search types. If the string provided is
        "Message" OR if it is unknown then the value returned will be
        SearchTypes.substructure.

        Usage:
        >>> search_type1 = SearchTypes.from_string("Exact")
        >>> print search_type1 # 2 => SearchTypes.Exact
        >>> search_type2 = SearchTypes.from_string("OtherString")
        >>> print search_type2 # 1 => SearchTypes.substructure

        :param search_type: The search type to convert from a string to a
                            numeric level
        :return: The numeric level returned
        :rtype: int
        """
        if search_type == "ss":
            return SearchTypes.substructure
        elif search_type == "ex":
            return SearchTypes.exact
        else:
            return SearchTypes.substructure


class EMoleculeWebServices:
    def __init__(self, client_software, client_key):
        """
        Create a new instance of EMoleculeWebServices with the provided
        credentials

        :param client_software:  The username to use while accessing the API
        :param client_key:       The password to use while accessing the API
        """
        self.client = suds.client.Client(WSDL_FILE_PATH, transport=_PinnedCertHttpTransport())

        self.access_hash = self.client.factory.create("securityHash")
        self.access_hash.clientSoftware = client_software
        self.access_hash.clientKey = client_key

        self._last_search_params = None

    def _serialise_search_params(self, 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.

        :param search_params:   The search params to write to file
        :return: None
        """

        search_details = {}
        if search_params:
            # Invalid search params provided - beyond end of search - null the
            # result
            if hasattr(search_params, 'np') and search_params.p >= search_params.np:
                self._last_search_params = None
                return

            for data in search_params:
                key = data[0]
                value = data[1]
                search_details[key] = value

        self._last_search_params = search_details

    def client_software(self):
        return self.access_hash.clientSoftware

    def client_key(self):
        return self.access_hash.clientKey

    def last_search_params(self):
        return self._last_search_params

    def set_last_search_params(self, last_search_params):
        self._last_search_params = last_search_params

    def emol_suppliers(self):
        """
        Get a list of eMolecules suppliers from the API

        :return:
        """

        result = None
        try:
            result = self.client.service.eMolSuppliers(
                accessHash=self.access_hash)
        except suds.WebFault as e:
            print(e)

        return result

    def structure_by_vid(self, vid):
        """
        Retrieve a structure MOL block by a specified vid

        :param vid: The desired vid to find an associated structure for
        :type vid: int
        :return:
        """

        result = None
        try:
            result = self.client.service.structureByVID(
                inputVID=vid,
                accessHash=self.access_hash)
        except suds.WebFault as e:
            print(e)

        return result

    def molecule_search(self, mol_string, search_type, search_field,
                        vendor=None):
        """
        Start a new structure search in eMolecules, returning at most 10 results
        at a time. To get more results from the search executed see
        continueSearch.

        This executes startSearch with a molString argument. For the variant
        with a queryString see querySearch.

        :param mol_string: The molecule string used in the search
        :param search_type: The type of data the search is looking to return
        :param search_field: The search method to run the molecule string against
        :param vendor: The vendor the search will be performed against
        :return:
        """

        result = None
        try:
            if vendor:
                result = self.client.service.startSearch(
                    molString=mol_string,
                    searchType=SearchTypes.to_string(search_type),
                    searchField=search_field,
                    vendor=vendor,
                    accessHash=self.access_hash)
            else:
                result = self.client.service.startSearch(
                    molString=mol_string,
                    searchType=SearchTypes.to_string(search_type),
                    searchField=search_field,
                    accessHash=self.access_hash)

            self._serialise_search_params(result["searchParams"])
        except suds.WebFault as e:
            print(e)

        return result

    def query_search(self, query_string, search_type, search_field, vendor=None):
        """
        Start a new structure search in eMolecules, returning at most 10 results
        at a time. To get more results from the search executed see
        continueSearch.

        This executes startSearch with a queryString argument. For the variant
        with a molString see moleculeSearch.

        :param query_string: The term used to perform the search
        :param search_type: The type of data the search is looking to return
        :param search_field: The search method to run the query string against
        :param vendor: The vendor the search will be performed against
        :return:
        """

        result = None
        try:
            if vendor:
                result = self.client.service.startSearch(
                    queryString=query_string,
                    searchType=SearchTypes.to_string(search_type),
                    searchField=search_field,
                    vendor=vendor,
                    accessHash=self.access_hash)
            else:
                result = self.client.service.startSearch(
                    queryString=query_string,
                    searchType=SearchTypes.to_string(search_type),
                    searchField=search_field,
                    accessHash=self.access_hash)

            self._serialise_search_params(result["searchParams"])
        except suds.WebFault as e:
            print(e)

        return result

    def continue_search(self):
        """
        Continue the last executed search (if one exists) and return the results
        - assuming of course there are any to return.

        :return:
        """

        # No search details to continue searching with
        if not self._last_search_params:
            return None

        # We've reached the end of the search space, cannot continue
        if self._last_search_params['p'] >= self._last_search_params['np']:
            return None

        result = None
        try:
            self._last_search_params['p'] += 1

            search_params = self.client.factory.create('params')
            for key in self._last_search_params:
                search_params.__setattr__(key, self._last_search_params[key])

            result = self.client.service.continue_search(
                searchParams=search_params,
                accessHash=self.access_hash)

            if result and "searchParams" in result:
                self._serialise_search_params(result["searchParams"])
        except suds.WebFault as e:
            print(e)

        return result
