# Requirements: click tqdm requests
import hashlib
import fnmatch
import sys
import packaging.version
from pathlib import Path

import requests
import click
from tqdm import tqdm

_input = input


def input(__prompt=None) -> str:
    try:
        return _input(__prompt)
    except KeyboardInterrupt:
        print("\nCancelled.")
        sys.exit(0)


def find_matching_versions(pattern: str, manifest: dict) -> list[dict]:
    return list(filter(lambda x: fnmatch.fnmatch(x["id"], pattern), manifest["versions"]))


def download(url: str, path: Path, version: str | None = None, **hashes) -> Path | None:
    if path.is_dir():
        name = "server.jar" if not version else "server-%s.jar" % version
        path = path / name

    if path.exists():
        if not yesno("%s already exists. Overwrite? [y/N] " % path.name):
            return
        try:
            path.unlink()
        except PermissionError:
            print("You have insufficient permissions to delete %s." % path.resolve())

    try:
        path.touch(0o755)
    except PermissionError:
        print("Permission denied while creating file at %s." % path.resolve())
        return

    try:
        response = requests.get(url, stream=True)
    except requests.exceptions.ConnectionError:
        print("Could not connect to %s." % url)
        return
    total_size = int(response.headers.get("content-length", 50000000))
    block_size = 4096
    print("Downloading %s..." % path.name)
    progress_bar = tqdm(total=total_size, unit="iB", unit_scale=True)
    with path.open("wb") as file:
        try:
            for chunk in response.iter_content(chunk_size=block_size):
                progress_bar.update(len(chunk))
                file.write(chunk)
        except KeyboardInterrupt:
            print("Download cancelled.")
            file.close()
            path.unlink()
            return
        except ConnectionError:
            print("Connection error while downloading %s." % path.name)
            file.close()
            path.unlink()
            return
        except Exception as e:
            print("An error occurred while downloading %s." % path.name)
            file.close()
            print(e)
            path.unlink()
            return

    progress_bar.close()
    progress_bar.clear()
    if hashes:
        for k, v in hashes.items():
            print("Verifying download (%s)..." % k)
            sha1_instance = hashlib.new(k)
            progress_bar = tqdm(total=total_size, unit="iB", unit_scale=True)
            with path.open("rb") as file:
                for chunk in iter(lambda: file.read(block_size), b""):
                    progress_bar.update(len(chunk))
                    sha1_instance.update(chunk)
            progress_bar.close()
            progress_bar.clear()
            if sha1_instance.hexdigest() != v:
                print("Download verification failed.")
                path.unlink()
                return
    return path


def yesno(prompt: str | None = None) -> bool:
    response = input(prompt)
    if not response.lower().startswith(("y", "n")):
        try:
            return yesno(prompt)
        except RecursionError:
            print("You're a moron.")
            return False

    return response.lower().startswith("y")


def select(options: list[str], prompt: str | None = None) -> str | None:
    options = list(map(lambda x: x.lower().strip(), options))
    response = input(prompt)
    if response.lower().strip() not in options:
        print("Invalid option. Try again.")
        try:
            return select(options, prompt)
        except RecursionError:
            print("You're a moron.")
            return
    return response.lower().strip()


def get_minecraft_manifest() -> dict:
    click.secho("Fetching minecraft version manifest...", fg="cyan")
    return requests.get("https://launchermeta.mojang.com/mc/game/version_manifest.json").json()


def get_version_manifest(version_id: str, manifest: dict) -> dict | None:
    click.secho("Fetching version manifest for version %r..." % version_id, fg="cyan")
    for version in manifest["versions"]:
        if version["id"] == version_id:
            return requests.get(version["url"]).json()
    print("Unknown version.")
    return


def get_papermc_manifest(version_id: str) -> dict | None:
    click.secho("Fetching PaperMC version manifest for version %r..." % version_id, fg="cyan")
    response = requests.get("https://api.papermc.io/v2/projects/paper/versions/%s/builds" % version_id)
    if not response.ok or response.headers.get("Content-Type") != "application/json":
        click.secho("Unknown version %r (HTTP %d)." % (version_id, response.status_code), fg="red")
        return
    return response.json()


def get_papermc_version_manifest(version_id: str, manifest: dict) -> dict | None:
    if not manifest.get("builds"):
        click.secho("No builds found for version %r." % version_id, fg="red")
        return
    filtered_builds = list(filter(lambda b: b.get("channel", "default") == "default", manifest["builds"]))
    if not filtered_builds:
        click.secho("Warning: No default channel builds found for version %r. Using latest." % version_id, fg="yellow")
        filtered_builds = manifest["builds"]
    latest_build = max(filtered_builds, key=lambda b: b["build"])
    click.secho("Selected build %d for PaperMC." % latest_build["build"], fg="cyan")
    manifest = {
        "downloads": {
            "server": {
                "sha256": latest_build["downloads"]["application"]["sha256"],
                "url": "https://api.papermc.io/v2/projects/paper/versions/%s/builds/%d/downloads/paper-%s-%d.jar" % (
                    version_id,
                    latest_build["build"],
                    version_id,
                    latest_build["build"]
                )
            }
        }
    }
    return manifest


def get_fabricmc_manifest(version_id: str) -> dict | None:
    click.secho("Fetching Fabric version manifest...", fg="cyan")
    supported_versions = requests.get("https://meta.fabricmc.net/v2/versions/game").json()
    versions = [v["version"] for v in supported_versions]
    if version_id not in versions:
        click.secho("Unsupported version. Must be 1.14 or later (or a snapshot later than 18w43b).", fg="red")
        return
    return requests.get("https://meta.fabricmc.net/v2/versions/loader/%s" % version_id).json()


def get_fabricmc_version_manifest(version_id: str, manifest: dict) -> dict | None:
    click.secho("Fetching Fabric version manifest for minecraft version %r..." % version_id, fg="cyan")
    response = requests.get("https://meta.fabricmc.net/v2/versions/loader/%s" % version_id)
    if not response.ok:
        return
    data = response.json()
    version = list(filter(lambda d: d["loader"].get("stable") is True, data))
    if not version:
        click.secho("Warning: No stable FabricMC releases for %s. Using latest release." % version_id, fg="yellow")
        version = data[0]["loader"]["version"]
    else:
        version = version[0]["loader"]["version"]
    click.secho("Fetching SHA256 checksum for Fabric Loader %s..." % version, fg="cyan")
    base = "https://maven.fabricmc.net/net/fabricmc/fabric-loader/{0}/fabric-loader-{0}.jar".format(version)
    sha256 = requests.get(base + ".sha256").text.strip()
    return {
        "downloads": {
            "server": {
                "url": base,
                "sha256": sha256,
            }
        }
    }


def download_vanilla_server(version_id: str, output: Path) -> Path | None:
    manifest = get_minecraft_manifest()
    version_manifest = get_version_manifest(version_id, manifest)
    if not version_manifest:
        return

    downloads = version_manifest["downloads"]
    if "server" not in downloads:
        print("No server download available for this version.")
        return

    server_download = downloads["server"]
    server_download_url = server_download["url"]
    server_download_sha1 = server_download["sha1"]
    return download(server_download_url, output, version_id, sha1=server_download_sha1)


def download_papermc_server(version_id: str, output: Path) -> Path | None:
    papermc_manifest = get_papermc_manifest(version_id)
    if not papermc_manifest:
        return

    version_manifest = get_papermc_version_manifest(version_id, papermc_manifest)
    if not version_manifest:
        return

    downloads = version_manifest["downloads"]
    if "server" not in downloads:
        print("No server download available for this version.")
        return

    server_download = downloads["server"]
    server_download_url = server_download["url"]
    server_download_sha256 = server_download["sha256"]
    return download(server_download_url, output, version_id, sha256=server_download_sha256)


def download_fabricmc_server(version_id: str, output: Path) -> Path | None:
    manifest = get_fabricmc_manifest(version_id)
    if not manifest:
        return
    
    version_manifest = get_fabricmc_version_manifest(version_id, manifest)
    if not version_manifest:
        return
    
    downloads = version_manifest["downloads"]
    if "server" not in downloads:
        print("No server download available for this version.")
        return

    server_download = downloads["server"]
    server_download_url = server_download["url"]
    server_download_sha256 = server_download["sha256"]
    return download(server_download_url, output, version_id, sha256=server_download_sha256)


SERVER_TYPES = {
    "vanilla": download_vanilla_server,
    "paper": download_papermc_server,
    "fabric": download_fabricmc_server,
}


@click.command()
@click.option("--list-all", "--list", "-L", is_flag=True, help="List available versions.")
@click.option("--output", "-O", "-o", "output", type=click.Path(), default=Path.cwd(), help="Output path.")
@click.option(
    "--type",
    "-T",
    "server_type",
    type=click.Choice(tuple(SERVER_TYPES.keys())),
    default="vanilla",
    help="Server type."
)
@click.option("--releases-only", "-R", is_flag=True, help="Only list releases, not snapshots and pre-releases.")
@click.argument("version", required=False, nargs=-1)
def main(list_all: bool, output, version: str, server_type: str, releases_only: bool):
    version = " ".join(version) if version else None
    output = Path(output)
    manifest = get_minecraft_manifest()
    if releases_only:
        manifest["versions"] = list(filter(lambda x: x["type"] == "release", manifest["versions"]))
    if list_all:
        for version in reversed(manifest["versions"]):
            print("\N{BULLET} " + version["id"])
        return

    if not version:
        for _version in reversed(manifest["versions"]):
            print("\N{BULLET} " + _version["id"])
        version_id = select(list(map(lambda x: x["id"], manifest["versions"])), "Select a version: ")
        if not version_id:
            return
    else:
        version_id = version
    
    version_ids = version_id.split(" ")
    for version_id in version_ids:
        if "*" in version_id:
            matches = find_matching_versions(version_id, manifest)
            if matches:
                version_ids.remove(version_id)
                version_ids.extend(list(map(lambda x: x["id"], matches)))
            else:
                click.secho("No matches for '%s'. Omitting." % version_id, fg="yellow")
                version_ids.remove(version_id)

    if len(version_ids) > 1:
        print("Downloading the following versions: %s" % ", ".join(version_ids))
    for version_id in version_ids:
        click.secho("Starting download for %s server %s." % (server_type, version_id), fg="cyan")
        result = SERVER_TYPES[server_type](version_id, output)
        if result:
            click.secho("Successfully downloaded %s server %s to %s." % (server_type, version_id, result), fg="green")
        else:
            click.secho("Failed to download %s server %s." % (server_type, version_id), fg="red")


if __name__ == "__main__":
    main()
