#!/usr/bin/env python3
# encoding: utf-8
"""
release.py

Created by Thomas Mangin on 2011-01-24.
Copyright (c) 2009-2017 Exa Networks. All rights reserved.
"""

import os
import re
import sys
import fileinput

import glob
import shutil
import zipapp
import zipfile
import argparse
import subprocess


CHANGELOG = 'doc/CHANGELOG.rst'


class Path:
    root = os.path.join(os.getcwd(), os.path.dirname(sys.argv[0]))
    changelog = os.path.join(root, CHANGELOG)
    lib_exa = os.path.join(root, 'src/exabgp')
    version = os.path.join(root, 'src/exabgp/version.py')
    pyproject = os.path.join(root, 'pyproject.toml')
    sbin = os.path.join(root, 'sbin/exabgp')
    debian = os.path.join(root, 'debian/changelog')
    egg = os.path.join(root, 'src/exabgp.egg-info')
    build_exabgp = os.path.join(root, 'build/src/exabgp')
    build_root = os.path.join(root, 'build')

    @classmethod
    def remove_egg(cls, *args):
        from shutil import rmtree

        print('removing left-over egg')
        if os.path.exists(cls.egg):
            rmtree(cls.egg)
        if os.path.exists(cls.build_exabgp):
            rmtree(cls.build_root)
        return 0


class Version:
    JSON = '5.0.0'
    TEXT = '5.0.0'

    template = """\
import os
import sys


def get_zipapp():
    return os.path.abspath(os.path.sep.join(__file__.split(os.path.sep)[:-2]))


def get_root():
    if os.path.isfile(get_zipapp()):
        return get_zipapp()
    return os.path.abspath(os.path.sep.join(__file__.split(os.path.sep)[:-1]))


commit = '%s'
release = '%s'
json = '%s'
text = '%s'
version = os.environ.get('exabgp_version', release)

# Do not change the first line as it is parsed by scripts

if sys.version_info.major < 3:
    sys.exit('exabgp requires python3.6 or later')
if sys.version_info.major == 3 and sys.version_info.minor < 6:
    sys.exit('exabgp requires python3.6 or later')

if __name__ == '__main__':
    sys.stdout.write(version)
"""

    @staticmethod
    def current():
        sys.path.append(Path.lib_exa)
        from version import version as release

        # transitional fix
        if '-' in release:
            release = release.split('-')[0]

        return release

    @staticmethod
    def changelog():
        with open(Path.changelog) as f:
            f.readline()
            for line in f:
                if line.lower().startswith('version '):
                    return line.split()[1].rstrip().rstrip(':').strip()
        return ''

    @staticmethod
    def set(tag, commit):
        if Command.dryrun:
            print('  [dry-run] would write src/exabgp/version.py')
            return True
        with open(Path.version, 'w') as f:
            f.write(Version.template % (commit, tag, Version.JSON, Version.TEXT))
        return True

    @staticmethod
    def latest(tags):
        valid = [[int(_) for _ in tag.split('.')] for tag in tags if Version.valid(tag)]
        return '.'.join(str(_) for _ in sorted(valid)[-1])

    @staticmethod
    def valid(tag):
        parts = tag.split('.')
        return len(parts) == 3 and parts[0].isdigit() and parts[1].isdigit() and parts[2].isdigit()

    @staticmethod
    def candidates(tag):
        latest = [int(_) for _ in tag.split('.')]
        return [
            '.'.join([str(_) for _ in (latest[0], latest[1], latest[2] + 1)]),
            '.'.join([str(_) for _ in (latest[0], latest[1] + 1, 0)]),
            '.'.join([str(_) for _ in (latest[0] + 1, 0, 0)]),
        ]


class Debian:
    template = """\
exabgp (%s-0) unstable; urgency=low

  * Latest ExaBGP release.

 -- Vincent Bernat <bernat@debian.org>  %s

"""

    @staticmethod
    def set(version):
        if Command.dryrun:
            print('  [dry-run] would write debian/changelog')
            return
        from email.utils import formatdate

        with open(Path.debian, 'w') as w:
            w.write(Debian.template % (version, formatdate()))
        print('updated debian/changelog')


class Command:
    dryrun = 'dry-run' if os.environ.get('DRY', os.environ.get('DRYRUN', os.environ.get('DRY_RUN', False))) else ''

    @staticmethod
    def run(cmd):
        print('>', cmd)
        return Git.dryrun or os.system(cmd)  # noqa: S605 - release tool, not user input


class Git(Command):
    @staticmethod
    def commit(comment):
        return Git.run('git commit -a -m "%s"' % comment)

    @staticmethod
    def push(tag='', repo=''):
        command = 'git push'
        if repo:
            command += ' %s' % repo
        if tag:
            command += ' %s' % tag
        return Git.run(command)

    @staticmethod
    def head_commit():
        return os.popen('git rev-parse --short HEAD').read().strip()

    @staticmethod
    def tags():
        return os.popen('git tag').read().strip().split('\n')

    @staticmethod
    def tag(release):
        return Git.run('git tag -s -a %s -m "release %s"' % (release, release))

    @staticmethod
    def pending():
        allowed = (
            'src/exabgp/version.py',
            # 'debian/changelog',
            'README.md',
            'doc/README.rst',
            'pyproject.toml',
            'sbin/exabgp',
            'uv.lock',
        )
        commit = None
        for line in os.popen('git status --porcelain').read().split('\n'):
            if not line.strip():
                continue
            status = line[:2]
            filepath = line[3:].strip()
            if status.strip() in ('M', 'MM', 'AM'):
                if any(filepath.endswith(a) for a in allowed):
                    if commit is not False:
                        commit = True
                else:
                    return False
            elif status.strip() in ('R', 'RM'):
                return False
        return commit

    @staticmethod
    def has_remote(name):
        return name in os.popen('git remote').read().strip().split('\n')


#
# Check that that there is no version inconsistancy before any pypi action
#


def update_file_version(filepath, current, release):
    """Replace current version with release version in a file"""
    if Command.dryrun:
        print('  [dry-run] would update %s' % filepath)
        return
    with fileinput.FileInput(filepath, inplace=True) as replacer:
        for line in replacer:
            print(line.replace(current, release), end='')


def release_github(args):
    print()
    print('updating Github')

    current = Version.current()
    print('current version is %s (from version.py)' % current)

    release = Version.changelog()
    print('release version is %s (from %s)' % (release, CHANGELOG))

    if not Version.valid(release):
        print('invalid new version in %s' % CHANGELOG)
        return 1

    tags = Git.tags()
    if release in tags:
        print(f'version {release} was already released/used')
        return 1

    candidates = Version.candidates(Version.latest(tags))
    if release not in candidates:
        print('valid versions are:', ', '.join(candidates))
        print('this release is not one of the candidates, aborting')
        return 1

    print('updating src/exabgp/version.py')
    Version.set(release, Git.head_commit())
    # print('updating debian/changelog')
    # Debian.set(release)

    print('updating ', end='')
    for path in ('README.md', 'doc/README.rst', 'pyproject.toml', 'sbin/exabgp'):
        print(path + ' ', end='')
        update_file_version(path, current, release)
    print()

    print('updating uv.lock')
    if not Command.dryrun:
        import subprocess

        subprocess.run(['uv', 'lock'], stderr=subprocess.DEVNULL)

    print('checking if we need to commit a version.py change')
    status = Git.pending()
    if status is None:
        print('all is already set for release')
    elif status is False:
        print('more than one file is modified and need updating, aborting')
        return 1
    else:
        if Git.commit('updating version to %s' % release):
            print('could not commit version change (%s)' % release)
            return 1
        print('version was updated')

    print('tagging the new version')
    if Git.tag(release):
        print('could not tag version (%s)' % release)
        return 1

    print('pushing the new tag')
    if Git.push(tag=release, repo='origin'):
        print('could not push release tag to origin')
        return 1

    if Git.push(repo='origin'):
        print('could not push release version to origin')
        return 1

    if Git.has_remote('upstream'):
        if Git.push(tag=release, repo='upstream'):
            print('could not push release tag to upstream')
            return 1

        if Git.push(repo='upstream'):
            print('could not push release version to upstream')
            return 1
    else:
        print('no upstream remote, skipping')

    return 0


def release_pypi(args):
    test = args.test

    print()
    print('updating PyPI')

    Path.remove_egg()

    if Command.run('python3 setup.py sdist bdist_wheel'):
        print('could not generate egg')
        return 1

    # keyring used to save credential
    # https://pypi.org/project/twine/

    release = Version.latest(Git.tags())

    server = ''
    if test:
        server = '--repository-url https://test.pypi.org/legacy/'

    if Command.run('twine upload %s dist/exabgp-%s.tar.gz dist/exabgp-%s-py3-none-any.whl' % (server, release, release)):
        print('could not upload with twine')
        return 1

    print('all done.')
    return 0


def remove_cache():
    for name in glob.glob('./src/*/__pycache__'):
        shutil.rmtree(name)
    for name in glob.glob('./src/*/*/__pycache__'):
        shutil.rmtree(name)

    for name in glob.glob('./src/*/*.pyc'):
        os.remove(name)
    for name in glob.glob('./src/*/*/*.pyc'):
        os.remove(name)


# the zipapp: the pure Python wheel, as one file any Python 3.8 or later runs
ZIPAPP_WORK = os.path.join(Path.root, 'build', 'zipapp')
ZIPAPP_DIST = os.path.join(Path.root, 'dist')
ZIPAPP_INTERPRETER = '/usr/bin/env python3'
ZIPAPP_DEBUG_INTERPRETER = '/usr/bin/env EXABGP_DEBUG_PDB=1 EXABGP_DEBUG_CONFIGURATION=1 EXABGP_DEBUG_ROUTE=1 python3'
# what runs first: python3 is whatever the PATH finds, which may be older than 3.8 and would
# otherwise stop on the SyntaxError of the first module using newer syntax. Written for any
# Python 3, so no f-string. It exits as the console script pip writes does
ZIPAPP_MAIN = """import sys

if sys.version_info < (3, 8):
    sys.exit('exabgp requires Python 3.8 or later, %s is Python %d.%d' % ((sys.executable,) + sys.version_info[:2]))

from exabgp.application.main import main

sys.exit(main())
"""
# what of the wheel the zipapp holds: not the example configurations of <name>.data
ZIPAPP_CONTENT = re.compile(r'^exabgp(-[^/]+\.dist-info)?/')
# the wheel holds a few hundred files: more is not the wheel this script built
ZIPAPP_MAX_MEMBERS = 5000
# what of the checkout the wheel is not built from
ZIPAPP_IGNORE = shutil.ignore_patterns('.git', '.venv', 'build', 'dist', '*.egg-info', '__pycache__', '*.pyc')
# the configuration the validation check uses
ZIPAPP_CONFIGURATION = os.path.join(Path.root, 'etc', 'exabgp', 'conf-ipself6.conf')
# the functional suites run against it, as CI runs them for the source tree
ZIPAPP_SUITES = ('parsing', 'decoding', 'encoding')
# a stuck exabgp fails the check rather than hang it
ZIPAPP_TIMEOUT = 60


def zipapp_wheel():
    """The pure Python wheel, built from a copy of the checkout, so the checkout is untouched."""
    source = os.path.join(ZIPAPP_WORK, 'source')
    wheels = os.path.join(ZIPAPP_WORK, 'wheel')
    for folder in (source, wheels):
        if os.path.exists(folder):
            shutil.rmtree(folder)
    shutil.copytree(Path.root, source, ignore=ZIPAPP_IGNORE)
    command = [sys.executable, '-m', 'pip', 'wheel', '--no-deps', '--wheel-dir', wheels, source]
    print('>', ' '.join(command), flush=True)
    subprocess.run(command, check=True)
    found = sorted(glob.glob(os.path.join(wheels, 'exabgp-*-py3-none-any.whl')))
    if len(found) != 1:
        sys.exit(f'expected one pure Python wheel in {wheels}, found {len(found)}')
    return found[0]


def zipapp_tree(wheel):
    """The package and its metadata out of the wheel, with the __main__.py checking the Python."""
    target = os.path.join(ZIPAPP_WORK, 'app')
    if os.path.exists(target):
        shutil.rmtree(target)
    with zipfile.ZipFile(wheel) as archive:
        names = archive.namelist()
        if len(names) > ZIPAPP_MAX_MEMBERS:
            sys.exit(f'{wheel} holds {len(names)} files, more than {ZIPAPP_MAX_MEMBERS}')
        archive.extractall(target, [name for name in names if ZIPAPP_CONTENT.match(name)])
    if not os.path.isfile(os.path.join(target, 'exabgp', 'application', 'main.py')):
        sys.exit(f'{wheel} does not hold the exabgp package')
    with open(os.path.join(target, '__main__.py'), 'w') as main:
        main.write(ZIPAPP_MAIN)
    return target


def zipapp_run(pyz, *arguments, env=None):
    """The output of the zipapp run with `arguments`, exiting if it fails."""
    result = subprocess.run([pyz] + list(arguments), capture_output=True, text=True, timeout=ZIPAPP_TIMEOUT, env=env)
    if result.returncode:
        sys.exit(f'{pyz} {" ".join(arguments)} failed:\n{result.stdout}{result.stderr}')
    return result.stdout


def check_binary(pyz, functional):
    """version and validate, then the functional suites, which run it through sbin/exabgp."""
    from exabgp.version import release

    # sbin/exabgp exports exabgp_version from git, which would hide the zipapp's own
    env = {key: value for key, value in os.environ.items() if key != 'exabgp_version'}
    report = zipapp_run(pyz, 'version', env=env)
    print(report, end='')
    if f'ExaBGP : {release}\n' not in report or f'From   : {pyz}\n' not in report:
        sys.exit(f'{pyz} does not run the package it holds, version {release}')
    zipapp_run(pyz, 'validate', '-nrv', ZIPAPP_CONFIGURATION)
    print(f'{pyz} runs version and validate')
    if not functional:
        return
    env = dict(os.environ, EXABGP_BINARY=pyz)
    program = os.path.join(Path.root, 'qa', 'bin', 'functional')
    for suite in ZIPAPP_SUITES:
        print('>', program, suite, flush=True)
        if subprocess.run([sys.executable, program, suite], env=env).returncode:
            sys.exit(f'the {suite} suite failed against {pyz}')


def generate_binary(args):
    """The zipapp, dist/exabgp-<version>.pyz unless a target is named, checked once built."""
    sys.path.insert(0, os.path.join(Path.root, 'src'))
    from exabgp.version import release

    if args.test:
        check_binary(os.path.abspath(args.test), args.functional)
        return 0
    os.makedirs(ZIPAPP_WORK, exist_ok=True)
    tree = zipapp_tree(zipapp_wheel())
    os.makedirs(ZIPAPP_DIST, exist_ok=True)
    target = os.path.abspath(args.target or os.path.join(ZIPAPP_DIST, f'exabgp-{release}.pyz'))
    interpreter = ZIPAPP_DEBUG_INTERPRETER if args.debug else ZIPAPP_INTERPRETER
    zipapp.create_archive(tree, target, interpreter=interpreter, compressed=not args.debug)
    print(f'built {target}')
    check_binary(target, args.functional)
    return 0


def show_version(args):
    if args.version == 'current':
        print(Version.current())
    if args.version == 'release':
        print(Version.changelog())


def main():
    # To late for it .. but here as a reminder that it can be done
    os.environ['PYTHONDONTWRITEBYTECODE'] = ''

    if os.environ.get('SCRUTINIZER', '') == 'true':
        sys.exit(0)

    if len(sys.argv) > 1 and sys.argv[1] == 'debian':
        release = Version.changelog()
        Debian.set(release)
        sys.exit(0)

    parser = argparse.ArgumentParser(description='exabgp release tool')
    root = parser.add_subparsers()

    binary = root.add_parser('binary', help='build the zipapp, one file any Python 3.8 or later runs')
    binary.add_argument('target', nargs='?', default='', help='the file to create (dist/exabgp-<version>.pyz)')
    binary.add_argument('-d', '--debug', help='run python with pdb', action='store_true')
    binary.add_argument('--test', metavar='PYZ', help='do not build, check this zipapp')
    binary.add_argument('--functional', action='store_true', help='also run the functional suites against it')
    binary.set_defaults(func=generate_binary)

    pypi = root.add_parser('pypi', help='create egg/wheel')
    pypi.add_argument('-t', '--test', help='only test', action='store_true')
    pypi.set_defaults(func=release_pypi)

    github = root.add_parser('github', help='tag a new version on github, and update pypi')
    github.set_defaults(func=release_github)

    cleanup = root.add_parser('cleanup', help='delete left-over file from release')
    cleanup.set_defaults(func=Path.remove_egg)

    show = root.add_parser('show', help='show exabgp version')
    show.add_argument('version', help='which version', choices=['current', 'release'])
    show.set_defaults(func=show_version)

    args = parser.parse_args()
    if 'func' not in dir(args):
        sys.exit(1)
    args.func(args)
    sys.exit(0)


if __name__ == '__main__':
    main()
