#!/usr/bin/python3

from datetime import datetime, timedelta
from glob import glob
from os import path, mkdir, symlink, system, unlink
import re
from shutil import rmtree
import socket
from sys import argv
from time import sleep

# Exit codes

EXIT_OK = 0
EXIT_INVOCATION = 1  # Incorrect usage (syntax error, invalid command, etc.)
EXIT_DOWNLOAD = 8  # Error downloading a remote file
EXIT_NOSYS = -1  # Unspecified error

# File locations
ABS_DIR = "/srv/minecraft"
SERVER_PROPS = "server.properties"
BACKUP_DIR = "backups"
LOG_DIR = "logs"
WARP_DIR = "plugins/Essentials/warps"
DIMENSIONS = {"", "_nether", "_the_end"}

# tmux
TMUX_SESSION = "minecraft"
STOP_TIMEOUT = timedelta(seconds=20)

# Properties
PROP_WORLD = "level-name"
PROP_SEED = "level-seed"
PROP_PORT = "port"


# Utility functions


def warp_files(world: str) -> str:
    """
    Get the filename selector for a world's warps.
    """
    return f"{WARP_DIR}/{world}_*.y*ml"


def get_json(url: str) -> str:
    """
    Download JSON from a URL and parse it.
    """
    from json import loads
    from urllib.request import Request, urlopen

    request = Request(url, headers={"User-Agent": "Mozilla/5.0"})
    json = urlopen(request).read().decode()
    return loads(json)


def get_prop(key: str) -> str:
    """
    Read a configuration variable from server.properties.
    """
    from configparser import ConfigParser

    # Read from disk
    parser = ConfigParser()
    with open(SERVER_PROPS) as f:
        parser.read_string("[DEFAULT]\n" + f.read())

    # Get requested property
    value = parser["DEFAULT"].get(key)
    if value:
        # Remove quotes
        return value.strip('"')
    else:
        return None


def set_prop(key: str, value):
    """
    Write a configuration variable in server.properties.
    """
    # Read from disk
    with open(SERVER_PROPS) as f:
        props = f.read()

    # Quote strings with special characters
    if re.match(r"[?{}|&~!()^]", value):
        value = f'"{value}"'

    # Update property
    props = re.sub(f"^{key}=(.*)$", f'{key}={value}', props, flags=re.MULTILINE)

    # Write to disk
    with open(SERVER_PROPS, "w") as f:
        f.write(props)


def send_cmd(cmd: str) -> bool:
    """
    Send a command to the running server.
    """
    return system(f"tmux send-keys -t {TMUX_SESSION}.0 '{cmd}' ENTER 1> /dev/null") == 0


def is_running() -> bool:
    """
    Check whether the server session exists.
    """
    return system(f"tmux has-session -t {TMUX_SESSION} 1> /dev/null") == 0


def is_up() -> bool:  
    """
    Check whether the server is listening on its assigned port.
    """
    port = int(get_prop(PROP_PORT) or "25565")
    s = socket.socket()

    try:
        s.connect(("localhost", port))
        return True
    except socket.error:
        return False
    finally:
        s.close()


# User-facing commands


def start(args: [str] = [], flags: [str] = []) -> int:
    """
    Start running the server.
    """
    if is_running():
        print("Server is already running.")
        return EXIT_NOSYS

    java_cmd = "java -Xms2G -Xmx4G -Djava.net.preferIPv4Stack=true -DIReallyKnowWhatIAmDoingISwear=true -jar server.jar nogui"
    return system(f'/usr/bin/tmux new-session -d -s {TMUX_SESSION} "cd {ABS_DIR} && {java_cmd}; echo STOPPED; read"')


def stop(args: [str] = [], flags: [str] = []) -> int:
    """
    Stop the running server.
    """
    if not is_running():
        print("Server is not running.")
        return EXIT_INVOCATION

    send_cmd("stop")

    # Wait for graceful shutdown
    deadline = datetime.now() + STOP_TIMEOUT
    while datetime.now() < deadline and is_up():
        sleep(1)

    # Force shutdown
    if is_up():
        system(f"tmux kill-session -t {TMUX_SESSION} 1> /dev/null")

    # Wait for complete stop
    sleep(1)
    send_cmd("")  # Continue past 'read'
    return int(not is_running())


def restart(args: [str] = [], flags: [str] = []) -> int:
    """
    Stop the server (if it is running) and then start it.
    """
    if is_running():
        stop()

    return start()


def reload(args: [str] = [], flags: [str] = []) -> int:
    """
    Reload the running server.
    """
    first_ok = send_cmd("reload")
    if not first_ok:
        return EXIT_NOSYS

    confirm_ok = send_cmd("reload confirm")
    if not confirm_ok:
        return EXIT_NOSYS

    return EXIT_OK


def promote(args: [str], flags: [str] = []) -> int:
    """
    Promote a player to the next role in the hierarchy.
    """
    if len(args) != 1:
        print("Username of player to promote must be specified.")
        return EXIT_INVOCATION

    return not send_cmd(f"lp user {args[0]} promote default")


def update(args: [str] = [], flags: [str] = []):
    """
    Download a Paper server JAR.
    If no version is specified, the latest version is used.
    NOTE: This restarts the server. To prevent this, use -q.
    """
    if len(args) == 1:
        version = args[0]
        print(f"Using specified version '{version}'")
    else:
        # Get latest Paper version
        version = get_json("https://papermc.io/api/v1/paper")["versions"][0]
        print(f"Using latest version '{version}'")

    if not "q" in flags:
        stop()
    
    # Get latest build of the selected Paper version
    build = get_json(f"https://papermc.io/api/v1/paper/{version}")["builds"]["latest"]
    print(f"Using build #{build}")

    # Download server JAR
    download_url = f"https://papermc.io/api/v1/paper/{version}/{build}/download"
    if system(f"wget {download_url} -O paper-{version}-{build}.jar") != 0:
        return EXIT_DOWNLOAD

    # Update link
    unlink("server.jar")
    symlink(f"paper-{version}-{build}.jar", "server.jar")

    if not "q" in flags:
        start()


def attach(args: [str] = [], flags: [str] = []):
    """
    Open the server console.
    """
    return system("tmux attach -t minecraft")


def clean(args: [str] = [], flags: [str] = []):
    """
    Delete old files to free up space.
    """
    # Delete old log files
    for log_file in glob(f"{LOG_DIR}/*.log.gz"):
        unlink(log_file)


def backup(args: [str] = [], flags: [str] = []) -> int:
    """
    Create a backup of one or more worlds.
    If no world names are specified, the currently loaded world name is used.
    """
    # Find world(s) to back up
    if len(args) >= 1:
        worlds = args
        print(f"Backing up selected worlds: {', '.join(worlds)}")
    else:
        worlds = [get_prop(PROP_WORLD)]
        print(f"Backing up active world '{worlds[0]}")

    # Back up selected world(s)
    failures = 0
    for world in worlds:
        dir_list = []

        # Figure out which dimensions to back up
        for dim in DIMENSIONS:
            if path.isdir(f"{world}{dim}/"):
                dir_list.append(f"{world}{dim}/")

        # Add warps if present
        if path.isdir(WARP_DIR):
            # Remove old warps directory
            if path.isdir("warps"):
                rmtree("warps")

            # Copy warps to temporary directory
            mkdir("warps")
            current_world = get_prop(PROP_WORLD)
            if glob(warp_files(current_world)):
                system(f"cp {warp_files(current_world)} warps")
            dir_list.append("warps/")

        # Create archive
        failures += system(f"tar -czvf {BACKUP_DIR}/{world}.tar.gz {' '.join(dir_list)}")

        # Remove temporary warps directory
        rmtree("warps")
    return failures


def restore(args: [str] = []) -> int:
    """
    Restore a world.
    If no world name is specified, the currently loaded world name is used.
    """
    # Find world to restore
    if len(args) == 1:
        world = args[0]
        print(f"Restoring selected world '{world}'")
    else:
        world = get_prop(PROP_WORLD)
        print(f"Restoring active world '{world}'")

    # Find backup file name
    archive = glob(f"{BACKUP_DIR}/{world}[._]*")

    if not archive:
        print("Could not find archive to restore")
        return EXIT_NOSYS

    # Remove current version
    for dim in DIMENSIONS | {"warps"}:
        folder = f"{world}{dim}"
        if path.isdir(folder):
            rmtree(folder)

    # Extract archive
    system(f"tar -xvzf {archive[0]}")

    if path.isdir("warps"):
        if glob("warps/*.y*ml"):
            # Copy warps out of temporary directory
            system(f"cp warps/*.y*ml {WARP_DIR}/")

        # Remove temporary directory
        rmtree("warps")


def switch(args: [str] = [], flags: [str] = []) -> int:
    """
    Switch to a different world.
    Requires the name of a world and optionally a seed for new worlds.
    This stops and restarts the server. To skip, use -q.
    This backs up the currently loaded world first. To skip, use -k.
    """
    if len(args) < 1:
        print("A world name must be specified")
        return EXIT_INVOCATION

    if "q" not in flags:
        print("[switch] Stopping server")
        stop()

    if "k" not in flags:
        print("[switch] Backing up current world")
        backup()

        try:
            current_world = get_prop(PROP_WORLD)
            
            print(f"[switch] Removing warps for '{current_world}'")
            for f in glob(warp_files(current_world)):
                unlink(f)
        except FileNotFoundError:
            pass

    print("[switch] Updating server properties")
    set_prop(PROP_WORLD, args[0])
    if len(args) == 2:
        set_prop(PROP_SEED, args[1])

    print("[switch] Attempting to restore archive")
    restore()

    if "q" not in flags:
        print("[switch] Starting server")
        start()

COMMANDS = {
    "update": update,
    "start": start,
    "stop": stop,
    "restart": restart,
    "reload": reload,
    "promote": promote,
    "attach": attach,
    "clean": clean,
    "backup": backup,
    "restore": restore,
    "switch": switch,
}


def help(args: [str] = [], flags: [str] = []):
    """
    View a list of commands.
    """
    for command, function in COMMANDS.items():
        print(command)

        if function.__doc__:
            print(function.__doc__)


if __name__ == "__main__": 
    COMMANDS["help"] = help

    if len(argv) == 1 or argv[1] not in COMMANDS:
        print(f"Valid commands are: {', '.join(COMMANDS.keys())}")
        exit(EXIT_INVOCATION)

    # Find arguments
    args = list(filter(lambda x: not x.startswith("-"), argv[2:]))

    # Find flags
    flags = []
    for f in filter(lambda x: x.startswith("-"), argv[2:]):
        f = f[1:]
        if len(f) > 1:
            flags.extend(list(f))
        else:
            flags.append(f)

    # Run command
    status = COMMANDS[argv[1]](args, flags)
    exit(status if status is not None else 0)
