import os
import re
import sys
import traceback
from subprocess import PIPE, Popen
from time import time
from typing import List, Optional

import requests

RELEASES_URL = 'https://api.github.com/repos/lavalink-devs/Lavalink/releases'
APPLICATION_BASE_URL = 'https://raw.githubusercontent.com/lavalink-devs/Lavalink/{}/LavalinkServer/application.yml.example'
SEMVER_REGEX = re.compile(r'(\d+)\.(\d+)(?:\.(\d+))?(?:-(\w+)(?:\.(\d+)))?')


class Release:
    def __init__(self, release_json):
        self.tag: str = release_json['tag_name']
        self.major_version: int = int(self.tag[0])
        self.prerelease: bool = release_json['prerelease']
        self.draft: bool = release_json['draft']

        assets = release_json['assets']
        jars = [asset['browser_download_url'] for asset in assets if asset['name'].endswith('.jar')]
        self.download_url: Optional[str] = jars[0] if jars else None

    def __str__(self) -> str:
        return f'{self.tag} {"[prerelease]" if self.prerelease else ""}'

    def __eq__(self, other):
        if not isinstance(other, Release):
            return False

        this_match = SEMVER_REGEX.match(self.tag)
        other_match = SEMVER_REGEX.match(other.tag)

        if not this_match or not other_match:
            raise ValueError('Cannot compare version strings as they do not match the regex pattern')

        this_major, this_minor, this_patch, _, this_build = this_match.groups()
        this_version = (int(this_major), int(this_minor), int(this_patch or 0), int(this_build or 0))

        other_major, other_minor, other_patch, _, other_build = other_match.groups()
        other_version = (int(other_major), int(other_minor), int(other_patch or 0), int(other_build or 0))

        return this_version == other_version

    def __lt__(self, other):
        this_match = SEMVER_REGEX.match(self.tag)

        if not this_match:
            raise ValueError('Cannot compare version strings as they do not match the regex pattern')

        this_major, this_minor, this_patch, _, this_build = this_match.groups()
        this_version = (int(this_major), int(this_minor), int(this_patch or 0), int(this_build or 0))

        if isinstance(other, str):
            parts = list(map(int, other.split('.')))

            if len(parts) == 1:
                other_version = (parts[0], 0, 0, 0)
            elif len(parts) == 2:
                other_version = (parts[0], parts[1], 0, 0)
            elif len(parts) == 3:
                other_version = (parts[0], parts[1], parts[2], 0)
            else:
                raise ValueError('Cannot compare version string with more fields than major.minor.patch')
        elif isinstance(other, Release):
            other_match = SEMVER_REGEX.match(other.tag)

            if not this_match or not other_match:
                raise ValueError('Cannot compare version strings as they do not match the regex pattern')

            other_major, other_minor, other_patch, _, other_build = other_match.groups()
            other_version = (int(other_major), int(other_minor), int(other_patch or 0), int(other_build or 0))
        else:
            raise TypeError(f'"<" not supported between instances of "{type(self).__name__}" and "{type(other).__name__}"')

        return this_version < other_version

    def __gt__(self, other):
        this_match = SEMVER_REGEX.match(self.tag)

        if not this_match:
            raise ValueError('Cannot compare version strings as they do not match the regex pattern')

        this_major, this_minor, this_patch, _, this_build = this_match.groups()
        this_version = (int(this_major), int(this_minor), int(this_patch or 0), int(this_build or 0))

        if isinstance(other, str):
            parts = list(map(int, other.split('.')))

            if len(parts) == 1:
                other_version = (parts[0], 0, 0, 0)
            elif len(parts) == 2:
                other_version = (parts[0], parts[1], 0, 0)
            elif len(parts) == 3:
                other_version = (parts[0], parts[1], parts[2], 0)
            else:
                raise ValueError('Cannot compare version string with more fields than major.minor.patch')
        elif isinstance(other, Release):
            other_match = SEMVER_REGEX.match(other.tag)

            if not this_match or not other_match:
                raise ValueError('Cannot compare version strings as they do not match the regex pattern')

            other_major, other_minor, other_patch, _, other_build = other_match.groups()
            other_version = (int(other_major), int(other_minor), int(other_patch or 0), int(other_build or 0))
        else:
            raise TypeError(f'">" not supported between instances of "{type(self).__name__}" and "{type(other).__name__}"')

        return this_version > other_version

    def __ge__(self, other):
        return self.__eq__(other) or self.__gt__(other)

    def __le__(self, other):
        return self.__eq__(other) or self.__lt__(other)


def display_help():
    print("""
download - Find and download specific Lavalink server versions.
    --no-overwrite  Renames an existing lavalink.jar to lavalink.old.jar
config   - Downloads a fresh application.yml.
    --fetch-dev     Fetches the latest application.yml from the development branch.
    --no-overwrite  Renames an existing application.yml to application.old.yml.
info     - Extracts version and build information from an existing Lavalink.jar.
    """.strip(), file=sys.stdout)


def format_bytes(length: int) -> str:
    sizes = ['B', 'KB', 'MB', 'GB', 'TB']
    unit = 0

    while length >= 1024 and unit < len(sizes) - 1:
        unit += 1
        length /= 1024

    return f'{length:.2f} {sizes[unit]}'


def download(dl_url, path):
    res = requests.get(dl_url, stream=True, timeout=15)

    download_begin = round(time() * 1000)

    def report_progress(cur, tot):
        bar_len = 32
        progress = float(cur) / tot
        filled_len = int(round(bar_len * progress))
        percent = round(progress * 100, 2)

        elapsed = round(time() * 1000) - download_begin
        if elapsed > 0:
            correction = 1000 / elapsed
            speed = cur * correction
        else:
            speed = 0  # placeholder until we have enough data to calculate

        progress_bar = '█' * filled_len + ' ' * (bar_len - filled_len)
        sys.stdout.write(f'Downloading |{progress_bar}| {percent:.1f}% ({cur}/{tot}, {format_bytes(speed)}/s)\r')
        sys.stdout.flush()

        if cur >= tot:
            sys.stdout.write('\n')

    def read_chunk(out, chunk_size=8192):
        total_bytes = int(res.headers['Content-Length'].strip())
        current_bytes = 0

        for chunk in res.iter_content(chunk_size):
            out.write(chunk)
            current_bytes += len(chunk)
            report_progress(min(current_bytes, total_bytes), total_bytes)

    with open(path, 'wb') as out:
        read_chunk(out)


def select_release_unattended(non_draft: List[Release], version_selector: str) -> Release:
    matcher = SEMVER_REGEX.match(version_selector)

    if matcher:
        def exact_version(release: Release):
            return release.tag == version_selector

        predicate = exact_version
    elif version_selector.startswith('>='):
        def gte(release: Release):
            return release >= version_selector[2:]

        predicate = gte
    elif version_selector.startswith('<='):
        def lte(release: Release):
            return release.tag <= version_selector[2:]

        predicate = lte
    elif version_selector.startswith('>'):
        def gt(release: Release):  # pylint: disable=C0103
            return release > version_selector[1:]

        predicate = gt
    elif version_selector.startswith('<'):
        def lte(release: Release):
            return release.tag < version_selector[1:]

        predicate = lte
    elif version_selector.startswith('~='):
        minimum = version_selector[2:]
        major, minor, _ = minimum.split('.')
        maximum = f'{major}.{int(minor) + 1}.0'

        def compatible(release: Release):
            return minimum <= release < maximum

        predicate = compatible
    else:
        # TODO: Support multiple version specifiers (e.g. >=3.7.0,<4.0.0)
        raise ValueError('Unsupported version selector')

    selected_release = next((release for release in non_draft if predicate(release)), None)

    if not selected_release:
        print('Couldn\'t find a suitable release with the provided version selector.', file=sys.stderr)
        sys.exit(1)

    print(f'Release selected: {selected_release.tag}', file=sys.stdout)

    return selected_release


def select_release(non_draft: List[Release]) -> Release:
    suitable_releases = []

    for release in non_draft:
        if not release.download_url:
            continue

        newest: Optional[Release] = next((sr for sr in suitable_releases if sr.major_version == release.major_version), None)

        if newest:
            if newest > release:  # GitHub gives newest->oldest releases, so it could be that we iterate over a pre-release before a release.
                if newest.prerelease and not release.prerelease:  # If that is the case, we check the version against the current non-prerelease
                    current_non_prerelease: Optional[Release] = next((sr for sr in suitable_releases if sr.major_version == release.major_version
                                                                      and not sr.prerelease), None)

                    if current_non_prerelease and current_non_prerelease > release:
                        continue
                else:
                    continue

        suitable_releases.append(release)

    if not suitable_releases:
        print('No suitable Lavalink releases were found.', file=sys.stdout)
        sys.exit(0)  # Perhaps this should be an error, however this could also be valid (but very unlikely).

    if len(suitable_releases) > 1:
        print('There are multiple Lavalink versions to choose from.\n'
              'They have automatically been filtered based on their version, and whether they are a pre-release.\n\n'
              'Type the number of the release you would like to download.\n', file=sys.stdout)

        for index, release in enumerate(suitable_releases, start=1):
            print(f'[{index}] {release}', file=sys.stdout)

        try:
            selected = int(input('> ')) - 1

            if not 0 <= selected <= len(suitable_releases):
                raise ValueError
        except ValueError:
            print('An incorrect selection has been made, cancelling...', file=sys.stderr)
            sys.exit(1)
    else:
        selected = 0

    return suitable_releases[selected]


def download_jar(arguments: List[str]):
    try:
        res = requests.get(RELEASES_URL, timeout=15).json()
    except requests.exceptions.JSONDecodeError:
        print('Failed to retrieve Lavalink releases', file=sys.stderr)
        sys.exit(1)

    releases = list(map(Release, res))
    non_draft = [r for r in releases if not r.draft]

    if arguments:
        try:
            release = select_release_unattended(non_draft, arguments[0])
        except ValueError:
            traceback.print_exc(file=sys.stderr)
            sys.exit(1)
    else:
        release = select_release(non_draft)

    cwd = os.getcwd()
    dl_url = release.download_url
    dl_path = os.path.join(cwd, 'lavalink.jar')

    if '--no-overwrite' in arguments and os.path.exists(dl_path):
        os.rename(dl_path, os.path.join(cwd, 'lavalink.old.jar'))

    download(dl_url, dl_path)
    print(f'Downloaded {release.tag} to {dl_path}', file=sys.stdout)
    sys.exit(0)


def download_config(arguments: List[str], branch: str):
    cwd = os.getcwd()
    dl_url = APPLICATION_BASE_URL.format(branch)
    dl_path = os.path.join(cwd, 'application.yml')

    if '--no-overwrite' in arguments and os.path.exists(dl_path):
        os.rename(dl_path, os.path.join(cwd, 'application.old.yml'))

    download(dl_url, dl_path)
    print(f'Downloaded to {dl_path}', file=sys.stdout)
    sys.exit(0)


def print_info(arguments: List[str]):
    check_names = ['lavalink.jar', 'Lavalink.jar', 'LAVALINK.JAR']

    if arguments:
        check_names.extend([arguments[0]])

    file_name = next((fn for fn in check_names if os.path.exists(fn)), None)

    if not file_name:
        print('Unable to display Lavalink server info: No Lavalink file found.', file=sys.stderr)
        sys.exit(1)

    proc = Popen(['java', '-jar', file_name, '--version'], stdout=PIPE, stderr=PIPE, text=True)
    stdout, stderr = proc.communicate()

    if stderr:
        if 'UnsupportedClassVersionError' in stderr:
            java_proc = Popen(['java', '-version'], stdout=PIPE, stderr=PIPE, text=True)
            j_stdout, j_stderr = java_proc.communicate()
            j_ver = re.search(r'java version "([\d._]*)"', j_stdout or j_stderr)
            java_version = j_ver.group(1) if j_ver else 'UNKNOWN'

            if java_version.startswith('1.8'):
                java_version = f'8/{java_version}'

            print(f'Unable to display Lavalink server info.\nYour Java version is out of date. (Java {java_version})\n\n'
                  'Java 11+ is required to run Lavalink.', file=sys.stderr)
            sys.exit(1)

        print(stderr, file=sys.stderr)
        sys.exit(1)
    else:
        print(stdout.strip(), file=sys.stdout)
        sys.exit(0)


def main():
    if len(sys.argv) < 2 or sys.argv[1] in ('--help', 'help', '?', '/help'):
        display_help()
        return

    _, action, *arguments = sys.argv
    target_branch = 'dev' if '--fetch-dev' in arguments else 'master'

    try:
        if action == 'download':
            download_jar(arguments)
        elif action == 'config':
            download_config(arguments, target_branch)
        elif action == 'info':
            print_info(arguments)
        else:
            print(f'Invalid argument \'{action}\'. Use --help to show usage.', file=sys.stderr)
            sys.exit(1)
    except KeyboardInterrupt:
        sys.exit(2)  # CTRL-C = SIGINT = 2


if __name__ == '__main__':
    main()
