import json
import urllib.request
import urllib.parse
import urllib.error
import GPS

#########################################
# Decorators and auto submodules import #
#########################################


class extend_module:
    """
    A decorator that should be used on a class.
    All methods in this class will override the methods for the
    homonym class in module.
    For instance:

        @extend_module(GPS)
        class File:
            def a_new_method(self):
                # A new method
                pass

            @override_gps_method
            def project(self):
                # Overrides, but call GPS-exported method
                return self._internal_project()

    """

    def __init__(self, module):
        """
        :type module: The module that contains the class to extend
        """
        self.module = module

    def __call__(self, kls):
        """
        :type kls: The type to extend
        """
        class_name = kls.__name__
        orig_class = getattr(self.module, class_name, None)

        if not orig_class:
            setattr(self.module, class_name, kls)
        else:
            for name, method in kls.__dict__.items():
                # Do not override built-in methods
                if name in ("__module__", "__dict__", "__doc__", "__weakref__"):
                    continue

                is_override = hasattr(method, "override_gps_method")

                if not hasattr(orig_class, name):
                    if is_override:
                        GPS.Console().write(
                            "Method %s.%s is not overriding\n" % (class_name, name)
                        )
                    setattr(orig_class, name, method)

                else:
                    if not is_override:
                        GPS.Console().write(
                            "Method %s.%s is overriding, ignored\n" % (class_name, name)
                        )
                    else:
                        # Save the gps internal method (can be useful to call
                        # it in the new implementation for example)

                        original_method = getattr(orig_class, name)
                        backup_name = "_internal_" + name
                        setattr(orig_class, backup_name, original_method)

                        # Add the new method
                        setattr(orig_class, name, method)

        return kls


def extend_gps(kls):
    """
    Extends classes from the GPS module.
    See `extend_module` for more information.
    """
    return extend_module(GPS)(kls)


def override_gps_method(method):
    """
    For use with the `extend_module` or `extend_gps` decorators.
    This should be used on methods that override the ones exported
    by GPS.
    """
    method.override_gps_method = True
    return method


############
# Contexts #
############

GPS.FileContext = GPS.Context
GPS.AreaContext = GPS.Context
GPS.EntityContext = GPS.Context
GPS.MessageContext = GPS.Context


######################
# General extensions #
######################


@extend_gps
class EditorView(object):
    def set_extend_selection(self, extend):
        """
        See :func:`GPS.EditorBuffer.extend_existing_selection`
        """
        self.buffer().extend_existing_selection = extend

    def get_extend_selection(self):
        """
        See :func:`GPS.EditorBuffer.extend_existing_selection`
        """
        return self.buffer().extend_existing_selection


class _UndoRedoContext(object):
    """Helper class to implement an undo/redo context manager."""

    def __init__(self, buffer):
        self.buffer = buffer

    def __enter__(self):
        self.buffer._start_undo_group()

    def __exit__(self, type, exc, tb):
        self.buffer._finish_undo_group()


@extend_gps
class EditorBuffer(object):
    __warned_about_undo_redo = False
    # Whether we have already warned about the deprecated undo grouping API

    def insert(self, loc_or_text, text=None):
        """
        Inserts some text in the buffer.

        :param EditorLocation loc_or_text: Either where to insert the text,
            or the text to insert in the buffer
        :type loc_or_text: string|EditorLocation

        :param string text: If the first passed parameter was a location,
            this is the text to be inserted. Else, it can be ignored.
        :type text: string|None

        .. seealso:: :func:`GPS.EditorBuffer.delete`
        """
        if isinstance(loc_or_text, GPS.EditorLocation):
            assert isinstance(text, str) or isinstance(text, str)
            self._insert_at_location(loc_or_text, text)
        else:
            text = loc_or_text
            assert isinstance(text, str) or isinstance(text, str)
            self._insert_at_location(self.current_view().cursor(), loc_or_text)

    def entity_under_cursor(self):
        """
        Shortcut to return a :class:`GPS.Entity` instance corresponding to the
        entity under cursor

        :rtype: :class:`GPS.Entity`
        """
        return self.main_cursor().location().entity()

    def start_undo_group(self):
        """This is deprecated. Use GPS.EditorBuffer.new_undo_group

        This is done via a context manager:

             with buffer.new_undo_group():
                 action 1
                 ...
                 action N
        """
        if not GPS.EditorBuffer.__warned_about_undo_redo:
            GPS.EditorBuffer.__warned_about_undo_redo = True
            GPS.Console().write(
                "GPS.EditorBuffer.start_undo_group is deprecated:"
                " use GPS.EditorBuffer.new_undo_group instead:\n\n"
            )
            GPS.Console().write(self.new_undo_group.__doc__)
        self._start_undo_group()
        if hasattr(self, "undo_group"):
            self.undo_group += 1
        else:
            self.undo_group = 1

    def finish_undo_group(self):
        """This is deprecated, use GPS.EditorBuffer.new_undo_group"""
        global warned_about_undo_redo
        if not warned_about_undo_redo:
            warned_about_undo_redo = True
            GPS.Console().write(
                "GPS.EditorBuffer.finish_undo_group is deprecated:"
                " use GPS.EditorBuffer.new_undo_group instead.\n"
            )
        if not hasattr(self, "undo_group"):
            GPS.Console().write(
                "Error: 'finish_undo_group' not matching 'start_undo_group'\n"
            )
        else:
            self.undo_group -= 1
            if self.undo_group >= 0:
                self._finish_undo_group()
            else:
                GPS.Console().write(
                    "Error: more calls to 'finish_undo_group'"
                    " than to 'start_undo_group'\n"
                )

    def new_undo_group(self):
        """Create a new undo group.

        This returns an object which should be used as a context manager.
        If you would like N actions to be considered as atomic for
        undo/redo, use this:

              with buffer.new_undo_group():
                  action 1
                  ...
                  action N
        """
        return _UndoRedoContext(self)


@extend_gps
class File(object):
    @property
    def uri(self):
        """Return an URI of the form file://<path> for the given file"""
        return urllib.parse.urljoin(
            "file:", urllib.parse.quote(urllib.request.pathname2url(self.name()))
        )


@extend_gps
class Cursor(object):
    def location(self):
        """
        Returns the cursor's location
        :rtype: :class:`GPS.EditorLocation`
        """
        return self.mark().location()


@extend_gps
class EditorLocation(object):
    def get_word(self):
        """
        This will return the word that contains this location, if there
        is one, the empty string otherwise. This is a shortcut method that uses
        the inside_word, starts_word and ends_word methods of
        `GPS.EditorLocation`.

        :returns: A tuple (word, start location, end location)
        :rtype: (unicode,
                 :class:`GPS.EditorLocation`, :class:`GPS.EditorLocation`)
        """

        word = ""
        start_loc = None
        end_loc = None

        if self.inside_word():
            start_loc = self
            while not start_loc.starts_word():
                start_loc = start_loc.forward_char(-1)

            end_loc = self
            while not end_loc.ends_word():
                end_loc = end_loc.forward_char()

            word = self.buffer().get_chars(start_loc, end_loc).strip()

        return word, start_loc, end_loc

    def entity(self):
        """
        Returns a :class:`GPS.Entity` instance at the given location.

        If there is no entity that can be resolved at this location,
        returns None

        :rtype: :class:`GPS.Entity`
        """
        try:
            word, start_loc, end_loc = self.get_word()
            return GPS.Entity(
                word, self.buffer().file(), start_loc.line(), start_loc.column()
            )
        except Exception:
            return None


@extend_gps
class BuildTarget(object):
    @override_gps_method
    def __init__(self, name):
        """
        Initializes a new instance of the class :class:`BuildTarget`. ``name``
        must correspond to an existing target.

        :param string name: Name of the target associated with this instance

        .. code-block:: python

           compile_file_target=GPS.BuildTarget("Compile File")
           compile_file_target.execute()
        """
        self.target_name = name
        self._internal___init__(name)

    @override_gps_method
    def execute(
        self,
        main_name="",
        file=None,
        force=False,
        extra_args="",
        build_mode="",
        synchronous=True,
        directory="",
        quiet=False,
        on_exit=None,
    ):
        """
        Launch the build target.

        :param string main_name: The base name of the main source to build, if
            this target acts on a main file.

        :param file: The file to build if this targets acts on a file.
        :type file: :class:`GPS.File`

        :param bool force:
           If True, this means that the target should be launched directly,
           even if its parameters indicate that it should be launched through
           an intermediary dialog.

        :param string|list[string] extra_args:
           any extra parameters to pass to the command line. When a single
           string is passed, it is split into multiple arguments.

        :param string build_mode:  Indicates build mode to be used for build.

        :param bool synchronous:
           if False, build target is launched asynchronously.
           ``compilation_finished hook`` will be called when build target
           execution is completed.

        :param string directory: A String

        :param bool quiet: A Boolean

        :param on_exit: A subprogram which will be called when the build target
           finishes executing. This subprogram takes as parameter an integer,
           representing the exit code of the command. For instance:

           GPS.BuildTarget("Custom...").execute(
               synchronous=True,
               on_exit=lambda status: GPS.MDI.dialog("status is %s" % status))
        """

        import workflows as wf

        if self.target_name in wf.workflows_target_name_set:
            idt = (self.target_name, main_name)
            # The workflow was already launched ...
            if (
                idt in wf.exit_handlers_table
                # ... and it has not finished
                and wf.exit_handlers_table[idt]
            ):
                GPS.Logger("BUILDTARGET").log(
                    "Workflow {} already in execution".format(idt)
                )
                return
            wf.exit_handlers_table[idt] = on_exit
            on_exit = None

        # Call the internal execute with given parameters
        self._internal_execute(
            main_name,
            file,
            force,
            extra_args,
            build_mode,
            synchronous,
            directory,
            quiet,
            on_exit,
        )


@extend_gps
class Language(object):
    @override_gps_method
    def __init__(self):
        """
        This constructor is provided to prevent the initialisation of any
        object of the Language class, because it is abstract. The
        consequence of this is that subclassers of Language must reimplement
        __init__ to avoid having an exception raised at instance construction
        time
        """
        raise NotImplementedError


@extend_gps
class Contextual(object):
    def create(
        self,
        on_activate,
        label=None,
        filter=None,
        ref="",
        add_before=True,
        group="0",
        visibility_filter=None,
        action=None,
    ):
        """
        OBSOLETE: please use GPS.Action.contextual() instead. All contextual
        menus should be associated with a specific action, so that this action
        can also be associated with a key shortcut, or reused in toolbars,...

        Creates a new contextual menu entry.  Whenever this menu entry is
        selected by the user, GPS executes :func:`on_activate`, passing one
        parameter which is the context for which the menu is displayed (this
        is usually the same as :func:`GPS.current_contextual`).

        If ``on_activate`` is None, a separator is created.

        The ``filter`` parameter can be used to filter when the entry should
        be displayed in the menu. It is a function that receives one
        parameter, an instance of :class:`GPS.Context`, and returns a
        boolean. If it returns True, the entry is displayed, otherwise it is
        hidden.

        The ``label`` parameter can be used to control the text displayed in
        the contextual menu.  By default, it is the same as the contextual
        name (used in the constructor to :func:`GPS.Contextual.__init__`).
        If specified, it must be a subprogram that takes an instance of
        :class:`GPS.Context` in a parameter and returns a string, which is
        displayed in the menu.

        The parameters ``group``, ``ref`` and ``add_before`` can be used to
        control the location of the entry within the contextual
        menu. ``group`` allows you to create groups of contextual menus that
        will be put together.  Items of the same group appear before all
        items with a greater group number.  ``ref`` is the name of another
        contextual menu entry, and add_before indicates whether the new entry
        is put before or after that second entry.

        :param self: An instance of GPS.Contextual
        :param on_activate: A subprogram with one parameter context
        :param label: A subprogram
        :param ref: A string
        :param add_before: A boolean
        :param filter: A subprogram
        :param group: An integer
        :param GPS.Action action: An action instance to be executed on menu
            activation

        .. code-block:: python

           ## This example demonstrates how to create a contextual
           ## menu with global functions

           def on_contextual(context):
              GPS.Console("Messages").write("You selected the custom entry")

           def on_filter(context):
              return context.entity_name() is not None

           def on_label(context):
              global count
              count += 1
              return "Custom " + count

           GPS.Contextual("Custom").create(
              on_activate=on_contextual, filter=on_filter, label=on_label)

           .. code-block:: python

           ## This example is similar to the one above, but uses a python
           ## class to encapsulate date.
           ## Note how the extra parameter self can be passed to the callbacks
           ## thanks to the call to self.create

           class My_Context(GPS.Contextual):
              def on_contextual(self, context):
                 GPS.Console("Messages").write(
                 "You selected the custom entry " + self.data)

              def on_filter(self, context):
                 return context.entity_name() is not None

              def on_label(self, context):
                 return self.data

              def __init__(self):
                 GPS.Contextual.__init__(self, "Custom")
                 self.data = "Menu Name"
                 self.create(on_activate=self.on_contextual,
                             filter=self.on_filter,
                             label=self.on_label)
        """

        # Unless we are creating a separator
        if not label or not label.endswith("-"):
            GPS.Logger("GPS.PYTHON").log(
                'GPS.Contextual("%s").create is deprecated.' % self.name
                + " Please use GPS.Action.contextual()"
            )
            if not action:
                # Create a dummy action
                action = GPS.Action("__%s" % self.name)
                action.create(
                    on_activate=lambda: on_activate(GPS.contextual_context()),
                    category="",  # hidden
                    filter=filter,
                )

            # Unused parameters: 'group' and 'visibility_filter'
            action.contextual(path=label or self.name, ref=ref, add_before=add_before)

        else:
            action = GPS.Action("__separator")
            action.contextual(path=label or self.name, ref=ref, add_before=add_before)


@extend_gps
class Menu(GPS.GUI):
    def set_sensitive(self, sensitive=True):
        """Disable the action associated with the menu"""
        self.action.disable(not sensitive)

    def destroy(self):
        """
        Remove the menu and all other graphical elements linked to
        the same action.
        """
        self.action.destroy_ui()

    def hide(self):
        """Disable the action associated with the menu"""
        self.action.disable(True)

    def show(self):
        """Enable the action associated with the menu"""
        self.action.disable(False)

    def pywidget(self):
        GPS.Console().write(
            "GPS.Menu.pywidget is no longer supported."
            + " Use GPS.Menu.action to interacte with the action"
            + " directly"
        )

    @staticmethod
    def create(path, on_activate="", ref="", add_before=True, filter=None, group=""):
        """
        Creates a new menu in the GPS system. The menu is added at the given
        location (see :func:`GPS.Menu.get` for more information on the
        ``path`` parameter). Submenus are created as necessary so ``path``
        is valid.

        It is recommended now to use :class:`gs_utils.interactive`
        instead of creating menus explicitly. The latter creates GPS
        actions, to which keybindings can be associated with the
        user. They can also be executed more conveniently using
        keyboard only with the omni-search.

        If ``on_activate`` is specified, it is executed every time the user
        selects that menu. It is called with only one parameter, the instance
        of :class:`GPS.Menu` that was just created.

        If ``ref`` and ``add_before`` are specified, they specify the name of
        another item in the parent menu (and not a full path) before or after
        which the new menu should be added.

        If the name of the menu starts with a '-' sign, as in "/Edit/-", a
        menu separator is inserted instead. In this case, on_activate is
        ignored.

        Underscore characters ('_') need to be duplicated in the path. A
        single underscore indicates the mnemonic to be used for that
        menu. For example, if you create the menu "/_File", then the user can
        open the menu by pressing :kbd:`Alt-f`. But the underscore itself
        is not be displayed in the name of the menu.

        If ``group`` is specified, create a radio menu item in given group.

        :param path: A string
        :param on_activate: A subprogram, see the GNAT Studio documentation on
            subprogram parameters
        :param ref: A string
        :param add_before: A boolean
        :param filter: A subprogram
        :param group: A string
        :return: The instance of :class:`GPS.Menu`

        .. code-block:: python

           def on_activate(self):
               print "A menu was selected: " + self.data

           menu = GPS.Menu.create("/Edit/My Company/My Action", on_activate)
           menu.data = "my own data"   ## Store your own data in the instance

        """

        if on_activate:
            GPS.Console().write(
                'GPS.Menu.create("%s") is deprecated.' % path
                + " Please use gs_utils.interactive()\n"
            )
            # Ignore 'group'
            m = None
            a = GPS.Action(path)
            a.create(lambda: on_activate(m), filter=filter)
            m = a.menu(path, add_before=add_before, ref=ref)


@extend_gps
class ToolButton(GPS.GUI):
    """Represents a Button that can be placed in the Toolbar.

    This class is provided for backwards compatibility only.
    Instead of using this, use GPS.Action and GPS.Action.button().
    """

    def __init__(self, stock_id, label, on_click):
        """This class is provided for backwards compatibility only.

        Instead of using this, use GPS.Action and GPS.Action.button().
        """
        self.action_name = "ToolButton {}:{}".format(label, on_click.__name__)
        self.on_click = on_click
        self.stock_id = stock_id


@extend_gps
class Button(GPS.GUI):
    """Represents a Button that can be placed in the Toolbar.

    This class is provided for backwards compatibility only.
    Instead of using this, use GPS.Action and GPS.Action.button().
    """

    def __init__(self, id, label, on_click):
        """This class is provided for backwards compatibility only.

        Instead of using this, use GPS.Action and GPS.Action.button().
        """
        self.action_name = label
        self.on_click = on_click
        self.stock_id = id

    def set_text(self, label):
        """Set the text of the button"""
        self.action_name = label


@extend_gps
class Toolbar(GPS.GUI):
    """The main GPS Toolbar.

    This class is provided for backwards compatibility only.
    Instead of using this, use GPS.Action and GPS.Action.button().
    """

    def __init__(self):
        """This class is provided for backwards compatibility only.

        Instead of using this, use GPS.Action and GPS.Action.button().
        """
        pass

    def append(self, widget, tooltip=""):
        """Append a widget to the Toolbar.

        The widget must be either a GPS.ToolButton or a GPS.Button."""
        self.action = GPS.Action(widget.action_name)
        self.action.create(
            on_activate=lambda: widget.on_click(widget),
            filter="",
            category="Custom ToolButtons",
            description=tooltip,
            icon=widget.stock_id,
        )
        self.action.button()

    def insert(self, widget, pos, tooltip):
        """Append a widget to the Toolbar.

        To insert a widget at a specific position in the toolbar, use
        GPS.Action.button instead, and set a value to the 'section'
        parameter.
        """
        GPS.Console("Messages").write(
            "GPS.Toolbar.insert() is deprecated: use GPS.Action.button.\n"
        )
        self.append(widget, tooltip)

    def get(self, id):
        """Does nothing and writes an error message

        This function is to help detect compatibility errors in plugins.
        """
        GPS.Console("Messages").write("GPS.Toolbar.get() is deprecated.\n")

    def get_by_pos(self, pos):
        """Does nothing and writes an error message

        This function is to help detect compatibility errors in plugins.
        """
        GPS.Console("Messages").write("GPS.Toolbar.get_by_pos() is deprecated.\n")


class LanguageServerResponse(object):
    """Represents a response sent by the language server"""

    def __init__(self):
        """Initialization procedure, meant to be called by request_promise"""

        self.is_valid = False
        # Whether the server responded with a response

        self.is_reject = False
        # Whether the request was rejected

        self.is_error = False
        # Whether the server returned an error

        self.error_message = None
        # The error message, if any

        self.data = None
        # The data received, if the response was valid. This is represented
        # as a Python view of the json response.

    def __str__(self):
        return (
            "is_valid: {}\nis_reject: {}\n"
            "is_error: {}\nerror: '{}'\ndata: '{}'\n".format(
                self.is_valid,
                self.is_reject,
                self.is_error,
                self.error_message,
                self.data,
            )
        )


@extend_gps
class LanguageServer(object):
    def request(
        self,
        method,
        params,
        on_result_message,
        on_error_message=None,
        on_rejected=None,
        auto_cancel=False,
    ):
        """
        Launch a request to the server.

        Works exactly like request_low_level, but accepts either a string
        or a dict as parameters.
        """

        if isinstance(params, str):
            json_params = params

        else:
            json_params = json.dumps(params)

        self.request_low_level(
            method,
            json_params,
            on_result_message,
            on_error_message,
            on_rejected,
            auto_cancel,
        )

    def request_promise(self, method, params, auto_cancel=False):
        """Make a language server request as a promise.

        method and params are the same arguments as request.

        The way to use this is in a workflow, in the following way:

            # Retrieve the language server
            als = GPS.LanguageServer.get_by_language_name("Ada")

            # call this with a yield
            result = yield als.request_promise(method, params)

            # result is a LanguageServerResponse object: typically
            # inspect result.is_valid, result.is_error, result.is_reject,
            # and process result.data if result.is_valid.
        """
        from workflows.promises import Promise

        p = Promise()
        result = LanguageServerResponse()

        def on_error(code, message, data):
            result.is_error = True
            result.error_message = message
            result.data = json.loads(data)
            p.resolve(result)

        def on_result(data):
            result.is_valid = True
            result.data = json.loads(data)
            p.resolve(result)

        def on_reject():
            result.is_reject = True
            p.resolve(result)

        self.request(method, params, on_result, on_error, on_reject, auto_cancel)
        return p
