import asyncio
import json
import logging
import signal as signal_module
import sys
import textwrap
from argparse import ArgumentParser, Namespace, RawTextHelpFormatter
from typing import Any, Dict, Optional

from graphql import GraphQLError, print_schema
from yarl import URL

from gql import Client, __version__, gql
from gql.transport import AsyncTransport
from gql.transport.exceptions import TransportQueryError

description = """
Send GraphQL queries from the command line using http(s) or websockets.
If used interactively, write your query, then use Ctrl-D (EOF) to execute it.
"""

examples = """
EXAMPLES
========

# Simple query using https
echo 'query { continent(code:"AF") { name } }' | \
gql-cli https://countries.trevorblades.com

# Simple query using websockets
echo 'query { continent(code:"AF") { name } }' | \
gql-cli wss://countries.trevorblades.com/graphql

# Query with variable
echo 'query getContinent($code:ID!) { continent(code:$code) { name } }' | \
gql-cli https://countries.trevorblades.com --variables code:AF

# Interactive usage (insert your query in the terminal, then press Ctrl-D to execute it)
gql-cli wss://countries.trevorblades.com/graphql --variables code:AF

# Execute query saved in a file
cat query.gql | gql-cli wss://countries.trevorblades.com/graphql

# Print the schema of the backend
gql-cli https://countries.trevorblades.com/graphql --print-schema

"""


def positive_int_or_none(value_str: str) -> Optional[int]:
    """Convert a string argument value into either an int or None.

    Raise a ValueError if the argument is negative or a string which is not "none"
    """
    try:
        value_int = int(value_str)
    except ValueError:
        if value_str.lower() == "none":
            return None
        else:
            raise

    if value_int < 0:
        raise ValueError

    return value_int


def get_parser(with_examples: bool = False) -> ArgumentParser:
    """Provides an ArgumentParser for the gql-cli script.

    This function is also used by sphinx to generate the script documentation.

    :param with_examples: set to False by default so that the examples are not
                          present in the sphinx docs (they are put there with
                          a different layout)
    """

    parser = ArgumentParser(
        description=description,
        epilog=examples if with_examples else None,
        formatter_class=RawTextHelpFormatter,
    )
    parser.add_argument(
        "server", help="the server url starting with http://, https://, ws:// or wss://"
    )
    parser.add_argument(
        "-V",
        "--variables",
        nargs="*",
        help="query variables in the form key:json_value",
    )
    parser.add_argument(
        "-H", "--headers", nargs="*", help="http headers in the form key:value"
    )
    parser.add_argument("--version", action="version", version=f"v{__version__}")
    group = parser.add_mutually_exclusive_group()
    group.add_argument(
        "-d",
        "--debug",
        help="print lots of debugging statements (loglevel==DEBUG)",
        action="store_const",
        dest="loglevel",
        const=logging.DEBUG,
    )
    group.add_argument(
        "-v",
        "--verbose",
        help="show low level messages (loglevel==INFO)",
        action="store_const",
        dest="loglevel",
        const=logging.INFO,
    )
    parser.add_argument(
        "-o",
        "--operation-name",
        help="set the operation_name value",
        dest="operation_name",
    )
    parser.add_argument(
        "--print-schema",
        help="get the schema from instrospection and print it",
        action="store_true",
        dest="print_schema",
    )
    parser.add_argument(
        "--schema-download",
        nargs="*",
        help=textwrap.dedent(
            """select the introspection query arguments to download the schema.
            Only useful if --print-schema is used.
            By default, it will:

             - request field descriptions
             - request deprecated input fields

            Possible options:

             - descriptions:false             for a compact schema without comments
             - input_value_deprecation:false  to omit deprecated input fields
             - specified_by_url:true
             - schema_description:true
             - directive_is_repeatable:true
             - input_object_one_of:true
             """
        ),
        dest="schema_download",
    )
    parser.add_argument(
        "--execute-timeout",
        help="set the execute_timeout argument of the Client (default: 10)",
        type=positive_int_or_none,
        default=10,
        dest="execute_timeout",
    )
    parser.add_argument(
        "--transport",
        default="auto",
        choices=[
            "auto",
            "aiohttp",
            "httpx",
            "phoenix",
            "websockets",
            "aiohttp_websockets",
            "appsync_http",
            "appsync_websockets",
        ],
        help=(
            "select the transport. 'auto' by default: "
            "aiohttp or websockets depending on url scheme"
        ),
        dest="transport",
    )

    appsync_description = """
By default, for an AppSync backend, the IAM authentication is chosen.

If you want API key or JWT authentication, you can provide one of the
following arguments:"""

    appsync_group = parser.add_argument_group(
        "AWS AppSync options", description=appsync_description
    )

    appsync_auth_group = appsync_group.add_mutually_exclusive_group()

    appsync_auth_group.add_argument(
        "--api-key",
        help="Provide an API key for authentication",
        dest="api_key",
    )

    appsync_auth_group.add_argument(
        "--jwt",
        help="Provide an JSON Web token for authentication",
        dest="jwt",
    )

    return parser


def get_transport_args(args: Namespace) -> Dict[str, Any]:
    """Extract extra arguments necessary for the transport
    from the parsed command line args

    Will create a headers dict by splitting the colon
    in the --headers arguments

    :param args: parsed command line arguments
    """

    transport_args: Dict[str, Any] = {}

    # Parse the headers argument
    headers = {}
    if args.headers is not None:
        for header in args.headers:

            try:
                # Split only the first colon (throw a ValueError if no colon is present)
                header_key, header_value = header.split(":", 1)

                headers[header_key] = header_value

            except ValueError:
                raise ValueError(f"Invalid header: {header}")

    if args.headers is not None:
        transport_args["headers"] = headers

    return transport_args


def get_execute_args(args: Namespace) -> Dict[str, Any]:
    """Extract extra arguments necessary for the execute or subscribe
    methods from the parsed command line args

    Extract the operation_name

    Extract the variable_values from the --variables argument
    by splitting the first colon, then loads the json value,
    We try to add double quotes around the value if it does not work first
    in order to simplify the passing of simple string values
    (we allow --variables KEY:VALUE instead of KEY:\"VALUE\")

    :param args: parsed command line arguments
    """

    execute_args: Dict[str, Any] = {}

    # Parse the operation_name argument
    if args.operation_name is not None:
        execute_args["operation_name"] = args.operation_name

    # Parse the variables argument
    if args.variables is not None:

        variables = {}

        for var in args.variables:

            try:
                # Split only the first colon
                # (throw a ValueError if no colon is present)
                variable_key, variable_json_value = var.split(":", 1)

                # Extract the json value,
                # trying with double quotes if it does not work
                try:
                    variable_value = json.loads(variable_json_value)
                except json.JSONDecodeError:
                    try:
                        variable_value = json.loads(f'"{variable_json_value}"')
                    except json.JSONDecodeError:
                        raise ValueError

                # Save the value in the variables dict
                variables[variable_key] = variable_value

            except ValueError:
                raise ValueError(f"Invalid variable: {var}")

        execute_args["variable_values"] = variables

    return execute_args


def autodetect_transport(url: URL) -> str:
    """Detects which transport should be used depending on url."""

    if url.scheme in ["ws", "wss"]:
        try:
            import websockets  # noqa: F401

            transport_name = "websockets"
        except ImportError:  # pragma: no cover
            transport_name = "aiohttp_websockets"

    else:
        assert url.scheme in ["http", "https"]

        try:
            from gql.transport.aiohttp import AIOHTTPTransport  # noqa: F401

            transport_name = "aiohttp"
        except ModuleNotFoundError:  # pragma: no cover
            try:
                from gql.transport.httpx import HTTPXAsyncTransport  # noqa: F401

                transport_name = "httpx"
            except ModuleNotFoundError:
                raise ModuleNotFoundError(
                    "\n\nNo suitable dependencies has been found for an http(s) backend"
                    " (aiohttp or httpx).\n\n"
                    "Please check the install documentation at:\n"
                    "https://gql.readthedocs.io/en/stable/intro.html#installation\n"
                )

    return transport_name


def get_transport(args: Namespace) -> Optional[AsyncTransport]:
    """Instantiate a transport from the parsed command line arguments

    :param args: parsed command line arguments
    """

    # Get the url scheme from server parameter
    url = URL(args.server)

    # Validate scheme
    if url.scheme not in ["http", "https", "ws", "wss"]:
        raise ValueError("URL protocol should be one of: http, https, ws, wss")

    # Get extra transport parameters from command line arguments
    # (headers)
    transport_args = get_transport_args(args)

    # Either use the requested transport or autodetect it
    if args.transport == "auto":
        transport_name = autodetect_transport(url)
    else:
        transport_name = args.transport

    # Import the correct transport class depending on the transport name
    if transport_name == "aiohttp":
        from gql.transport.aiohttp import AIOHTTPTransport

        return AIOHTTPTransport(url=args.server, **transport_args)

    elif transport_name == "httpx":
        from gql.transport.httpx import HTTPXAsyncTransport

        return HTTPXAsyncTransport(url=args.server, **transport_args)

    elif transport_name == "phoenix":
        from gql.transport.phoenix_channel_websockets import (
            PhoenixChannelWebsocketsTransport,
        )

        return PhoenixChannelWebsocketsTransport(url=args.server, **transport_args)

    elif transport_name == "websockets":
        from gql.transport.websockets import WebsocketsTransport

        transport_args["ssl"] = url.scheme == "wss"

        return WebsocketsTransport(url=args.server, **transport_args)

    elif transport_name == "aiohttp_websockets":
        from gql.transport.aiohttp_websockets import AIOHTTPWebsocketsTransport

        return AIOHTTPWebsocketsTransport(url=args.server, **transport_args)

    else:

        from gql.transport.appsync_auth import AppSyncAuthentication

        assert transport_name in ["appsync_http", "appsync_websockets"]
        assert url.host is not None

        auth: AppSyncAuthentication

        if args.api_key:
            from gql.transport.appsync_auth import AppSyncApiKeyAuthentication

            auth = AppSyncApiKeyAuthentication(host=url.host, api_key=args.api_key)

        elif args.jwt:
            from gql.transport.appsync_auth import AppSyncJWTAuthentication

            auth = AppSyncJWTAuthentication(host=url.host, jwt=args.jwt)

        else:
            from botocore.exceptions import NoRegionError

            from gql.transport.appsync_auth import AppSyncIAMAuthentication

            try:
                auth = AppSyncIAMAuthentication(host=url.host)
            except NoRegionError:
                # A warning message has been printed in the console
                return None

        transport_args["auth"] = auth

        if transport_name == "appsync_http":
            from gql.transport.aiohttp import AIOHTTPTransport

            return AIOHTTPTransport(url=args.server, **transport_args)

        else:
            from gql.transport.appsync_websockets import AppSyncWebsocketsTransport

            try:
                return AppSyncWebsocketsTransport(url=args.server, **transport_args)
            except Exception:
                # This is for the NoCredentialsError but we cannot import it here
                return None


def get_introspection_args(args: Namespace) -> Dict:
    """Get the introspection args depending on the schema_download argument"""

    # Parse the headers argument
    introspection_args = {}

    possible_args = [
        "descriptions",
        "specified_by_url",
        "directive_is_repeatable",
        "schema_description",
        "input_value_deprecation",
        "input_object_one_of",
    ]

    if args.schema_download is not None:
        for arg in args.schema_download:

            try:
                # Split only the first colon (throw a ValueError if no colon is present)
                arg_key, arg_value = arg.split(":", 1)

                if arg_key not in possible_args:
                    raise ValueError(f"Invalid schema_download: {args.schema_download}")

                arg_value = arg_value.lower()
                if arg_value not in ["true", "false"]:
                    raise ValueError(f"Invalid schema_download: {args.schema_download}")

                introspection_args[arg_key] = arg_value == "true"

            except ValueError:
                raise ValueError(f"Invalid schema_download: {args.schema_download}")

    return introspection_args


async def main(args: Namespace) -> int:
    """Main entrypoint of the gql-cli script

    :param args: The parsed command line arguments
    :return: The script exit code (0 = ok, 1 = error)
    """

    # Set requested log level
    if args.loglevel is not None:
        logging.basicConfig(level=args.loglevel)

    try:
        # Instantiate transport from command line arguments
        transport = get_transport(args)

        if transport is None:
            return 1

        # Get extra execute parameters from command line arguments
        # (variables, operation_name)
        execute_args = get_execute_args(args)

    except ValueError as e:
        print(f"Error: {e}", file=sys.stderr)
        return 1
    except ModuleNotFoundError as e:  # pragma: no cover
        print(f"Error: {e}", file=sys.stderr)
        return 2

    # By default, the exit_code is 0 (everything is ok)
    exit_code = 0

    # Connect to the backend and provide a session
    async with Client(
        transport=transport,
        fetch_schema_from_transport=args.print_schema,
        introspection_args=get_introspection_args(args),
        execute_timeout=args.execute_timeout,
    ) as session:

        if args.print_schema:
            schema_str = print_schema(session.client.schema)
            print(schema_str)

            return exit_code

        while True:

            # Read multiple lines from input and trim whitespaces
            # Will read until EOF character is received (Ctrl-D)
            query_str = sys.stdin.read().strip()

            # Exit if query is empty
            if len(query_str) == 0:
                break

            # Parse query, continue on error
            try:
                query = gql(query_str)
            except GraphQLError as e:
                print(e, file=sys.stderr)
                exit_code = 1
                continue

            # Execute or Subscribe the query depending on transport
            try:
                try:
                    async for result in session.subscribe(query, **execute_args):
                        print(json.dumps(result))
                except KeyboardInterrupt:  # pragma: no cover
                    pass
                except NotImplementedError:
                    result = await session.execute(query, **execute_args)
                    print(json.dumps(result))
            except (GraphQLError, TransportQueryError) as e:
                print(e, file=sys.stderr)
                exit_code = 1

    return exit_code


def gql_cli() -> None:
    """Synchronously invoke ``main`` with the parsed command line arguments.

    Formerly ``scripts/gql-cli``, now registered as an ``entry_point``
    """
    # Get arguments from command line
    parser = get_parser(with_examples=True)
    args = parser.parse_args()

    try:
        # Create a new asyncio event loop
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)

        # Create a gql-cli task with the supplied arguments
        main_task = asyncio.ensure_future(main(args), loop=loop)

        # Add signal handlers to close gql-cli cleanly on Control-C
        for signal_name in ["SIGINT", "SIGTERM", "CTRL_C_EVENT", "CTRL_BREAK_EVENT"]:
            signal = getattr(signal_module, signal_name, None)

            if signal is None:
                continue

            try:
                loop.add_signal_handler(signal, main_task.cancel)
            except NotImplementedError:  # pragma: no cover
                # not all signals supported on all platforms
                pass

        # Run the asyncio loop to execute the task
        exit_code = 0
        try:
            exit_code = loop.run_until_complete(main_task)
        finally:
            loop.close()

        # Return with the correct exit code
        sys.exit(exit_code)
    except KeyboardInterrupt:  # pragma: no cover
        pass
