from __future__ import annotations

import inspect
import optparse
import os
import shlex
import sys
import traceback
from ast import literal_eval as safe_eval
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, NoReturn, TypeVar, cast

if TYPE_CHECKING:
    from IPython.core.interactiveshell import InteractiveShell

    from . import MetaKernel

_F = TypeVar("_F", bound=Callable[..., Any])

_maxsize = sys.maxsize


class MagicOptionParser(optparse.OptionParser):
    def error(self, msg: str) -> NoReturn:
        raise Exception(f'Magic Parse error: "{msg}"')

    def exit(self, status: int = 0, msg: Any = None) -> NoReturn:
        if msg:
            sys.stderr.write(msg)
        raise Exception(msg)

    ## FIXME: override help to also stop processing
    ## currently --help gives syntax error


class Magic:
    """
    Base class to define magics for MetaKernel based kernels.

    Users can redefine the default magics provided by Metakernel
    by creating a module with the exact same name as the
    Metakernel magic.

    For example, you can override %matplotlib in your kernel by
    writing a new magic inside magics/matplotlib_magic.py
    """

    def __init__(self, kernel: MetaKernel) -> None:
        self.kernel = kernel
        self.evaluate = True
        self.code = ""

    def get_args(self, mtype: str, name: str, code: str, args: Any) -> Any:
        self.code = code
        old_args = args
        mtype = mtype.replace("sticky", "cell")

        func = getattr(self, mtype + "_" + name)
        try:
            args, kwargs = _parse_args(func, args, usage=self.get_help(mtype, name))
        except Exception as e:
            self.kernel.Error(str(e))
            return self
        arg_spec = inspect.getfullargspec(func)
        fargs = arg_spec.args
        if fargs[0] == "self":
            fargs = fargs[1:]

        fargs = [f for f in fargs if f not in kwargs.keys()]
        if len(args) > len(fargs) and not arg_spec.varargs:
            extra = " ".join(str(s) for s in (args[len(fargs) - 1 :]))
            args = args[: len(fargs) - 1] + [extra]

        return (args, kwargs, old_args)

    def call_magic(self, mtype: str, name: str, code: str, args: Any) -> Magic:
        self.code = code
        old_args = args
        mtype = mtype.replace("sticky", "cell")

        func = getattr(self, mtype + "_" + name)
        try:
            args, kwargs = _parse_args(func, args, usage=self.get_help(mtype, name))
        except Exception as e:
            self.kernel.Error(str(e))
            return self

        arg_spec = inspect.getfullargspec(func)
        fargs = arg_spec.args
        if fargs[0] == "self":
            fargs = fargs[1:]

        fargs = [f for f in fargs if f not in kwargs.keys()]
        if len(args) > len(fargs) and not arg_spec.varargs:
            extra = " ".join(str(s) for s in (args[len(fargs) - 1 :]))
            args = args[: len(fargs) - 1] + [extra]

        try:
            try:
                func(*args, **kwargs)
            except TypeError:
                func(old_args)
        except Exception as exc:
            msg = f"Error in calling magic '{name}' on {mtype}:\n    {exc!s}\n    args: {args}\n    kwargs: {kwargs}"
            self.kernel.Error(msg)
            self.kernel.Error(traceback.format_exc())
            self.kernel.Error(self.get_help(mtype, name))
            # return dummy magic to end processing:
            return Magic(self.kernel)
        return self

    def get_help(self, mtype: str, name: str, level: int = 0) -> str:
        if hasattr(self, mtype + "_" + name):
            func = getattr(self, mtype + "_" + name)
            if level == 0:
                if func.__doc__:
                    return _trim(func.__doc__)  # type: ignore[return-value]
                else:
                    return f"No help available for magic '{name}' for {mtype}s."
            else:
                filename = inspect.getfile(func)
                if filename and os.path.exists(filename):
                    with open(filename) as f:
                        return f.read()
                else:
                    return f"No help available for magic '{name}' for {mtype}s."
        else:
            return f"No such magic '{name}' for {mtype}s."

    def get_help_on(self, info: dict[str, Any], level: int = 0) -> str | None:
        return "Sorry, no help is available on '{}'.".format(info["code"])

    def get_completions(self, info: dict[str, Any]) -> list[str]:
        """
        Get completions based on info dict from magic.
        """
        return []

    def get_magics(self, mtype: str) -> list[str]:
        magics = []
        for name in dir(self):
            if name.startswith(mtype + "_"):
                magics.append(name.replace(mtype + "_", ""))
        return magics

    def get_code(self) -> str:
        return self.code

    def post_process(self, retval: Any) -> Any:
        return retval


def get_ipython() -> InteractiveShell | None:
    """Return the running IPython shell instance, or None if not in IPython."""
    from IPython import get_ipython as _get_ipython  # type: ignore[attr-defined]

    return cast("InteractiveShell | None", _get_ipython())  # type: ignore[no-untyped-call]


def option(*args: Any, **kwargs: Any) -> Callable[[_F], _F]:
    """Return decorator that adds a magic option to a function."""

    def decorator(func: _F) -> _F:
        help_text = ""
        if not getattr(func, "has_options", False):
            func.has_options = True  # type:ignore[attr-defined]
            func.options = []  # type:ignore[attr-defined]
            help_text += "Options:\n-------\n"
        try:
            option = optparse.Option(*args, **kwargs)
        except optparse.OptionError:
            help_text += args[0] + "\n"
        else:
            help_text += _format_option(option) + "\n"
            func.options.append(option)  # type:ignore[attr-defined]
        if func.__doc__:
            func.__doc__ += _indent(func.__doc__, help_text)
        else:
            func.__doc__ = help_text
        return func

    return decorator


def register_line_magic(func: _F) -> _F:
    """Register a function as an IPython line magic, preserving its type."""
    from IPython.core.magic import register_line_magic as _rlm

    _rlm(func)
    return func


def register_cell_magic(func: _F) -> _F:
    """Register a function as an IPython cell magic, preserving its type."""
    from IPython.core.magic import register_cell_magic as _rcm

    _rcm(func)
    return func


def _parse_args(
    func: Any, args: Any, usage: Any = None
) -> tuple[list[Any], dict[str, Any]]:
    """Parse the arguments given to a magic function"""
    if isinstance(args, list):
        args = " ".join(args)

    args = _split_args(args)

    kwargs = dict()
    if getattr(func, "has_options", False):
        parser = MagicOptionParser(usage=usage, conflict_handler="resolve")
        parser.add_options(func.options)

        left = []
        value = None
        if "--" in args:
            left = args[: args.index("--")]
            value, args = parser.parse_args(args[args.index("--") + 1 :])
        else:
            while args:
                try:
                    value, args = parser.parse_args(args)
                except Exception:
                    left.append(args.pop(0))
                else:
                    break
        args = left + args
        if value:
            kwargs = value.__dict__

    new_args = []
    for arg in args:
        try:
            new_args.append(safe_eval(arg))
        except Exception:
            new_args.append(arg)

    for key, value in kwargs.items():
        try:
            kwargs[key] = safe_eval(value)
        except Exception:  # noqa: S110
            pass

    return new_args, kwargs


def _split_args(args: Any) -> list[Any]:
    try:
        # do not use posix mode, to avoid eating quote characters
        args = shlex.split(args, posix=False)
    except Exception:
        # parse error; let's pass args along rather than crashing
        args = args.split()

    new_args = []
    temp = ""
    for arg in args:
        if arg.startswith("-"):
            new_args.append(arg)

        elif temp:
            arg = temp + " " + arg
            try:
                safe_eval(arg)
            except Exception:
                temp = arg
            else:
                new_args.append(arg)
                temp = ""

        elif arg.startswith(("(", "[", "{")) or "(" in arg:
            try:
                safe_eval(arg)
            except Exception:
                temp = arg
            else:
                new_args.append(arg)

        else:
            new_args.append(arg)

    if temp:
        new_args.append(temp)

    return new_args


def _format_option(option: Any) -> str:
    output = ""
    if option._short_opts:
        output = option._short_opts[0] + " "
    output += option.get_opt_string() + " "
    output += " " * (15 - len(output))
    output += option.help + " "
    if not option.default == ("NO", "DEFAULT"):
        output += f"[default: {option.default}]"
    return str(output)


def _trim(docstring: str, return_lines: bool = False) -> str | list[str]:
    """
    Trim of unnecessary leading indentations.
    """
    # from: http://legacy.python.org/dev/peps/pep-0257/
    if not docstring:
        return ""
    # Convert tabs to spaces (following the normal Python rules)
    # and split into a list of lines:
    lines = docstring.expandtabs().splitlines()
    indent = _min_indent(lines)
    # Remove indentation (first line is special):
    trimmed = [lines[0].strip()]
    if indent < _maxsize:
        for line in lines[1:]:
            trimmed.append(line[indent:].rstrip())
    # Strip off trailing and leading blank lines:
    while trimmed and not trimmed[-1]:
        trimmed.pop()
    while trimmed and not trimmed[0]:
        trimmed.pop(0)
    if return_lines:
        return trimmed
    else:
        # Return a single string:
        return "\n".join(trimmed)


def _min_indent(lines: list[str]) -> int:
    """
    Determine minimum indentation (first line doesn't count):
    """
    indent = _maxsize
    for line in lines[1:]:
        stripped = line.lstrip()
        if stripped:
            indent = min(indent, len(line) - len(stripped))
    return indent


def _indent(docstring: str, text: str) -> str:
    """
    Returns text indented at appropriate indententation level.
    """
    if not docstring:
        return text
    lines = docstring.expandtabs().splitlines()
    indent = _min_indent(lines)
    if indent < _maxsize:
        newlines = _trim(text, return_lines=True)
        return "\n" + ("\n".join([(" " * indent) + line for line in newlines]))
    else:
        return "\n" + text
