from __future__ import annotations

import dns.resolver
import sys
import json
import argparse
import socket
import dataclasses
from typing import TypeAlias

from mcstatus import JavaServer, LegacyServer, BedrockServer
from mcstatus.responses import JavaStatusResponse
from mcstatus.motd import Motd

SupportedServers: TypeAlias = "JavaServer | LegacyServer | BedrockServer"

PING_PACKET_FAIL_WARNING = (
    "warning: contacting {address} failed with a 'ping' packet but succeeded with a 'status' packet,\n"
    "         this is likely a bug in the server-side implementation.\n"
    '         (note: ping packet failed due to "{ping_exc}")\n'
    "         for more details, see: https://mcstatus.readthedocs.io/en/stable/pages/faq/\n"
)

QUERY_FAIL_WARNING = (
    "The server did not respond to the query protocol."
    "\nPlease ensure that the server has enable-query turned on,"
    " and that the necessary port (same as server-port unless query-port is set) is open in any firewall(s)."
    "\nSee https://minecraft.wiki/w/Query for further information."
)


def _motd(motd: Motd) -> str:
    """Formats MOTD for human-readable output, with leading line break
    if multiline."""
    s = motd.to_ansi()
    return f"\n{s}" if "\n" in s else f" {s}"


def _kind(serv: SupportedServers) -> str:
    if isinstance(serv, JavaServer):
        return "Java"
    elif isinstance(serv, LegacyServer):
        return "Java (pre-1.7)"
    elif isinstance(serv, BedrockServer):
        return "Bedrock"
    else:
        raise ValueError(f"unsupported server for kind: {serv}")


def _ping_with_fallback(server: SupportedServers) -> float:
    # only Java has ping method
    if not isinstance(server, JavaServer):
        return server.status().latency

    # try faster ping packet first, falling back to status with a warning.
    ping_exc = None
    try:
        return server.ping(tries=1)
    except Exception as e:
        ping_exc = e

    latency = server.status().latency

    address = f"{server.address.host}:{server.address.port}"
    print(
        PING_PACKET_FAIL_WARNING.format(address=address, ping_exc=ping_exc),
        file=sys.stderr,
    )

    return latency


def ping_cmd(server: SupportedServers) -> int:
    print(_ping_with_fallback(server))
    return 0


def status_cmd(server: SupportedServers) -> int:
    response = server.status()

    java_res = response if isinstance(response, JavaStatusResponse) else None

    if not java_res:
        player_sample = ""
    elif java_res.players.sample is not None:
        player_sample = str([f"{player.name} ({player.id})" for player in java_res.players.sample])
    else:
        player_sample = "No players online"

    if player_sample:
        player_sample = " " + player_sample

    print(f"version: {_kind(server)} {response.version.name} (protocol {response.version.protocol})")
    print(f"motd:{_motd(response.motd)}")
    print(f"players: {response.players.online}/{response.players.max}{player_sample}")
    print(f"ping: {response.latency:.2f} ms")
    return 0


def json_cmd(server: SupportedServers) -> int:
    data = {"online": False, "kind": _kind(server)}

    status_res = query_res = exn = None
    try:
        status_res = server.status(tries=1)
    except Exception as e:
        exn = exn or e

    try:
        if isinstance(server, JavaServer):
            query_res = server.query(tries=1)
    except Exception as e:
        exn = exn or e

    # construct 'data' dict outside try/except to ensure data processing errors
    # are noticed.
    data["online"] = bool(status_res or query_res)
    if not data["online"]:
        assert exn, "server offline but no exception?"
        data["error"] = str(exn)

    if status_res is not None:
        data["status"] = dataclasses.asdict(status_res)

        # ensure we are overwriting the motd and not making a new dict field
        assert "motd" in data["status"], "motd field missing. has it been renamed?"
        data["status"]["motd"] = status_res.motd.simplify().to_minecraft()

    if query_res is not None:
        # TODO: QueryResponse is not (yet?) a dataclass
        data["query"] = qdata = {}

        qdata["ip"] = query_res.raw["hostip"]
        qdata["port"] = query_res.raw["hostport"]
        qdata["map"] = query_res.map_name
        qdata["plugins"] = query_res.software.plugins
        qdata["raw"] = query_res.raw

    json.dump(data, sys.stdout)
    return 0


def query_cmd(server: SupportedServers) -> int:
    if not isinstance(server, JavaServer):
        print("The 'query' protocol is only supported by Java servers.", file=sys.stderr)
        return 1

    try:
        response = server.query()
    except socket.timeout:
        print(QUERY_FAIL_WARNING, file=sys.stderr)
        return 1

    print(f"host: {response.raw['hostip']}:{response.raw['hostport']}")
    print(f"software: {_kind(server)} {response.software.version} {response.software.brand}")
    print(f"motd:{_motd(response.motd)}")
    print(f"plugins: {response.software.plugins}")
    print(f"players: {response.players.online}/{response.players.max} {response.players.list}")
    return 0


def main(argv: list[str] = sys.argv[1:]) -> int:
    parser = argparse.ArgumentParser(
        "mcstatus",
        description="""
        mcstatus provides an easy way to query Minecraft servers for any information
        they can expose. It provides three modes of access: query, status, ping and json.
        """,
    )

    parser.add_argument("address", help="The address of the server.")
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--bedrock", help="Specifies that 'address' is a Bedrock server (default: Java).", action="store_true")
    group.add_argument(
        "--legacy", help="Specifies that 'address' is a pre-1.7 Java server (default: 1.7+).", action="store_true"
    )

    subparsers = parser.add_subparsers(title="commands", description="Command to run, defaults to 'status'.")
    parser.set_defaults(func=status_cmd)

    subparsers.add_parser("ping", help="Ping server for latency.").set_defaults(func=ping_cmd)
    subparsers.add_parser("status", help="Prints server status.").set_defaults(func=status_cmd)
    subparsers.add_parser(
        "query", help="Prints detailed server information. Must be enabled in servers' server.properties file."
    ).set_defaults(func=query_cmd)
    subparsers.add_parser(
        "json",
        help="Prints server status and query in json.",
    ).set_defaults(func=json_cmd)

    args = parser.parse_args(argv)
    if args.bedrock:
        lookup = BedrockServer.lookup
    elif args.legacy:
        lookup = LegacyServer.lookup
    else:
        lookup = JavaServer.lookup

    try:
        server = lookup(args.address)
        return args.func(server)
    except (socket.timeout, socket.gaierror, dns.resolver.NoNameservers, ConnectionError, TimeoutError) as e:
        # catch and hide traceback for expected user-facing errors
        print(f"Error: {e!r}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())
