#!/usr/bin/env python3
"""
cvsconvert - convert a CVS repo and check against the original

Convert, and check the tree content of a gitspace conversion against
the CVS.  The tip state of every branch, and every tag, is checked.

Will produce spurious errors if any CVS branch name had to be sanitized.
"""
#  SPDX-License-Identifier: GPL-2.0+
#
# The engine for this is the testlifter.py code from the distribution directory.
# Modify it there, not here
#
# This code runs correctly under both Python 2 and Python 3.
# Preserve this property!

# Ugh. This is needed because pylint removed the no-self-use check in 2.14.
# This is the only wqy to prevent spurious pylint failures on earlier versions.
# pylint: disable=useless-option-value

# pylint: disable=line-too-long,invalid-name,missing-class-docstring,useless-object-inheritance,missing-function-docstring,redefined-outer-name,consider-using-f-string,too-many-statements,arguments-renamed

# pylint: disable=multiple-imports
import sys, os, shutil, subprocess, time, filecmp

try:
    from pipes import quote
except ImportError:
    from shlex import quote

DEBUG_STEPS = 1
DEBUG_COMMANDS = 2
DEBUG_VCS = 3
DEBUG_LIFTER = 4

verbose = 0

os.putenv("PATH", os.getenv("PATH") + os.pathsep + "..")

binary_encoding = "Latin-1"  # Preserves high bits in data


def noisy_run(dcmd, legend=""):
    "Either execute a command or raise a fatal exception."
    if legend:
        legend = " " + legend
    caller = os.path.basename(sys.argv[0])
    if verbose >= DEBUG_COMMANDS:
        sys.stdout.write("%s: executing '%s'%s\n" % (caller, dcmd, legend))
    try:
        retcode = subprocess.call(dcmd, shell=True)
        if retcode < 0:
            sys.stderr.write(
                "%s: %s was terminated by signal %d.\n" % (-caller, dcmd, retcode)
            )
            sys.exit(1)
        elif retcode != 0:
            sys.stderr.write("%s: %s returned %d.\n" % (caller, dcmd, retcode))
            return False
    except (OSError, IOError) as e:
        sys.stderr.write(
            "%s: execution of %s%s failed: %s\n" % (caller, dcmd, legend, e)
        )
        return False
    return True


def capture_or_die(dcmd, legend=""):
    "Either execute a command and capture its output or die."
    if legend:
        legend = " " + legend
    caller = os.path.basename(sys.argv[0])
    if verbose >= DEBUG_COMMANDS:
        sys.stdout.write("%s: executing '%s'%s\n" % (caller, dcmd, legend))
    try:
        return (
            subprocess.Popen(dcmd, shell=True, stdout=subprocess.PIPE)
            .communicate()[0]
            .decode(binary_encoding)
        )
    except subprocess.CalledProcessError as e:
        if e.returncode < 0:
            sys.stderr.write(
                "%s: child was terminated by signal %d." % (caller, -e.returncode)
            )
        elif e.returncode != 0:
            sys.stderr.write("%s: child returned %d." % (caller, e.returncode))
        sys.exit(1)


class directory_context(object):
    def __init__(self, target):
        self.target = target
        self.source = None

    def __enter__(self):
        if verbose >= DEBUG_COMMANDS:
            sys.stdout.write("In %s: " % os.path.relpath(self.target))
        self.source = os.getcwd()
        if os.path.isdir(self.target):
            os.chdir(self.target)

    def __exit__(self, extype, value_unused, traceback_unused):
        os.chdir(self.source)


class RCSRepository(object):
    "An RCS file collection."

    def __init__(self, name):
        self.name = name
        self.retain = False
        self.directory = os.path.join(os.getcwd(), self.name)
        self.conversions = []

    def run_with_cleanup(self, cmd):
        if not noisy_run(cmd):
            if not self.retain:
                self.cleanup()
            sys.exit(1)

    def do(self, cmd, *args):
        "Execute a RCS command in context of this repo."
        if verbose < DEBUG_VCS:
            mute = "-q"
        else:
            mute = ""
        if (
            not noisy_run(
                "cd %s && %s %s %s" % (self.directory, cmd, mute, " ".join(args))
            )
            and not self.retain
        ):
            self.cleanup()
            sys.exit(1)

    def init(self):
        "Initialize the repository."
        self.run_with_cleanup("rm -fr {0} && mkdir -p {0}".format(self.directory))

    def write(self, fn, content):
        "Create file content in the repository."
        if verbose >= DEBUG_COMMANDS:
            sys.stdout.write("%s <- %s" % (fn, content))
        with directory_context(self.directory):
            with open(fn, "w", encoding="ascii", errors="surrogateescape") as fp:
                fp.write(content)

    def add(self, filename):
        "Add a file to the version-controlled set."
        self.do("rcs", "-t-", "-i", filename)

    def tag(self, filename, name):
        "Create a tag on a specified file."
        self.do("rcs", "-n" + name + ":", filename)

    def checkout(self, filename):
        "Check out a writeable copy of the specified file."
        self.do("co", "-l", filename)

    def checkin(self, filename, message):
        "Check in changes to the specified file."
        self.do("ci", "-m'%s' %s" % (message, filename))

    def stream(self, smodule, _gitdir, outfile, more_opts=""):
        directory = os.path.join(self.directory, smodule)
        vopt = "-v " * (verbose - DEBUG_LIFTER + 1)
        # The -L is necessary to handle proxied directories.
        self.run_with_cleanup(
            'find -L {0} -name "*,v" | cvs-fast-export {1} {2} >{3}'.format(
                directory, vopt, more_opts, outfile
            )
        )

    def convert(self, smodule, gitdir, more_opts=""):
        "Convert the repo."
        streamfile = "%s-%s.git.fi" % (self.name, module)
        self.stream(smodule, gitdir, streamfile, more_opts)
        self.run_with_cleanup(
            "rm -fr {0} && mkdir {0} && git init --quiet {0}".format(gitdir)
        )
        self.run_with_cleanup(
            "cat {1} | (cd {0} >/dev/null; git fast-import --quiet --done && git checkout)".format(
                gitdir, streamfile
            )
        )
        self.conversions.append(gitdir)
        if not self.retain:
            os.remove(streamfile)

    def cleanup(self):
        "Clean up the repository conversions."
        if not self.retain:
            if self.conversions:
                os.system("rm -fr %s" % " ".join(self.conversions))


class CVSRepository(RCSRepository):
    def __init__(self, name, readonly=False):
        RCSRepository.__init__(self, name)
        self.readonly = readonly
        self.directory = os.path.join(os.getcwd(), self.name)
        self.checkouts = []
        self.conversions = []

    def do(self, *cmd):
        "Execute a CVS command in context of this repo."
        if verbose < DEBUG_VCS:
            mute = "-Q"
        else:
            mute = ""
        if self.readonly:
            prefix = "CVSREADONLYFS=yes "
        else:
            prefix = ""
        self.run_with_cleanup(
            "%scvs %s -d:local:%s %s" % (prefix, mute, self.directory, " ".join(cmd))
        )

    def init(self):
        RCSRepository.init(self)
        self.do("init")

    def module(self, mname):
        "Create an empty module with a specified name."
        module = os.path.join(self.directory, mname)
        if verbose >= DEBUG_COMMANDS:
            sys.stdout.write("Creating module %s\n" % module)
        os.mkdir(module)

    # pylint: disable=arguments-differ
    def checkout(self, module, checkout=None):
        "Create a checkout of this repo."
        self.checkouts.append(CVSCheckout(self, module, checkout))
        return self.checkouts[-1]

    def cleanup(self):
        "Clean up the repository checkout directories."
        if not self.retain:
            RCSRepository.cleanup(self)
            for checkout in self.checkouts:
                checkout.cleanup()


class CVSCheckout(object):
    PROXYSUFFIX = "-proxy"
    SUFFIX = ".checkout"

    def __init__(self, repo, module, checkout=None):
        self.repo = repo
        self.module = module or "module"
        self.name = checkout or "%s-%s%s" % (repo.name, module, CVSCheckout.SUFFIX)
        # Hack to get around repositories that don't have a CVSROOT & module
        self.proxy = None
        if not os.path.exists(self.repo.directory + os.sep + "CVSROOT"):
            self.proxy = self.repo.name + CVSCheckout.PROXYSUFFIX
            try:
                shutil.rmtree(self.proxy)
            except OSError:
                pass
            os.mkdir(self.proxy)
            os.symlink(self.repo.directory, self.proxy + os.sep + self.module)
            os.mkdir(self.proxy + os.sep + "CVSROOT")
            self.repo.name += CVSCheckout.PROXYSUFFIX
            self.repo.directory += CVSCheckout.PROXYSUFFIX
        if os.path.exists(self.name):
            shutil.rmtree(self.name)
        self.repo.do("co", self.module)
        os.rename(self.module, self.name)
        self.directory = os.path.join(os.getcwd(), self.name)

    def do(self, cmd, *args):
        "Execute a command in the checkout directory."
        with directory_context(self.directory):
            self.repo.do(*([cmd] + list(args)))

    def outdo(self, cmd):
        "Execute a command in the checkout directory."
        with directory_context(self.directory):
            self.repo.run_with_cleanup(cmd)

    def add(self, *filenames):
        "Add a file to the version-controlled set."
        self.do(*["add"] + list(filenames))

    def remove(self, *files):
        "Remove a file from the version-controlled set."
        self.do(*["remove", "-f"] + list(files))

    def branch(self, branchname):
        "Create a new branch."
        self.do("tag", branchname + "_root")
        self.do("tag", "-r", branchname + "_root", "-b", branchname)
        self.do("up", "-r", branchname)

    def switch(self, branch="HEAD"):
        "Switch to an existing branch."
        self.do("up", "-A")
        if branch != "HEAD":
            self.do("up", "-r", "'" + branch + "'")

    def tag(self, name):
        "Create a tag."
        self.do("tag", name)

    def merge(self, branchname):
        "Merge a branch to trunk."
        # See https://kb.wisc.edu/middleware/page.php?id=4087
        self.do("tag", "merge_" + branchname)
        self.do("up", "-A")
        self.do("up", "-j", branchname)

    def commit(self, message):
        "Commit changes to the repository."
        # The CVS tools weren't designed to be called in rapid-fire
        # succession by scripts; they have race conditions.  This
        # presents misbehavior.
        time.sleep(2)
        self.do(*["commit", "-m '%s'" % message])

    def write(self, fn, content):
        "Create file content in the repository."
        if verbose >= DEBUG_COMMANDS:
            sys.stdout.write("%s <- %s" % (fn, content))
        with directory_context(self.directory):
            with open(fn, "w", encoding="ascii", errors="surrogateescape") as fp:
                fp.write(content)

    def append(self, fn, content):
        "Append to file content in the repository."
        if verbose >= DEBUG_COMMANDS:
            sys.stdout.write("%s <-| %s" % (fn, content))
        with directory_context(self.directory):
            with open(fn, "a", encoding="ascii", errors="surrogateescape") as fp:
                fp.write(content)

    def update(self, rev):
        "Update the content to the specified revision or tag."
        if rev == "master":
            rev = "HEAD"
        self.do("up", "-kb", "-A", "-r", "'" + rev + "'")

    def cleanup(self):
        "Clean up the checkout directory."
        if self.proxy and os.path.exists(self.proxy):
            shutil.rmtree(self.proxy)
        if os.path.exists(self.directory):
            shutil.rmtree(self.directory)


def expect_same(a, b):
    "Complain if two files aren't identical"
    if not os.path.exists(a):
        sys.stderr.write("%s does not exist in CVS.\n" % a)
        return
    if not os.path.exists(b):
        sys.stderr.write("%s does not exist in the git conversion.\n" % b)
        return
    if not filecmp.cmp(a, b, shallow=False):
        sys.stderr.write("%s and %s are not the same.\n" % (a, b))


def expect_different(a, b):
    "Rejoice if two files are unexpectedly identical"
    if not os.path.exists(a):
        sys.stderr.write("%s does not exist in CVS.\n" % a)
        return
    if not os.path.exists(b):
        sys.stderr.write("%s does not exist in the git conversion.\n" % b)
        return
    if filecmp.cmp(a, b, shallow=False):
        sys.stderr.write("%s and %s are unexpectedly the same.\n" % (a, b))


def junkbranch(name):
    "Is this a synthetic branch generated by cvs-fast-export?"
    return name.startswith("import-") or name.find("UNNAMED") != -1


class ConvertComparison(object):
    "Compare a CVS repository and its conversion for equality."
    # Needs to stay synchronized with reposurgeon's generic conversion makefile
    SUFFIX = "-git"
    # pylint: disable=too-many-arguments
    def __init__(
        self,
        srepo=None,
        smodule=None,
        checkout=None,
        options="",
        showdiffs=False,
        tapify=False,
    ):
        self.repo = CVSRepository(srepo, readonly=True)
        self.checkout = self.repo.checkout(smodule, checkout)
        self.gitRepoName = "%s-%s%s" % (
            self.repo.name,
            self.checkout.module,
            ConvertComparison.SUFFIX,
        )
        self.showdiffs = showdiffs
        self.tapify = tapify
        self.repo.convert(self.checkout.module, self.gitRepoName, more_opts=options)

        with directory_context(self.gitRepoName):
            self.branches = [
                name
                for name in capture_or_die("git branch -l").split()
                if name != "*" and not junkbranch(name)
            ]
            self.tags = capture_or_die("git tag -l").split()
        self.branches.sort()
        if "master" in self.branches:
            self.branches.remove("master")
            self.branches = ["master"] + self.branches

    # pylint: disable=too-many-branches,too-many-locals
    def compare_tree(self, legend, ref, success_expected=True):
        "Test to see if a tag or branch checkout has the expected content."
        preamble = "%s %s %s: " % (self.repo.name, legend, ref)
        if self.tapify:
            preamble = "not ok = " + preamble
        if ref not in self.tags and ref not in self.branches:
            if success_expected:
                sys.stderr.write(preamble + "%s unexpectedly missing.\n" % ref)
            return False

        def ftw(mydir, ignore):
            for root, _, files in os.walk(mydir):
                for walkfile in files:
                    path = os.path.join(root, walkfile)
                    if (
                        ignore not in path.split(os.sep)
                        and not path.endswith(".cvsignore")
                        and not path.endswith(".gitignore")
                    ):
                        yield path

        self.checkout.update(ref)
        with directory_context(self.gitRepoName):
            if not noisy_run("git checkout --quiet %s" % quote(ref)):
                self.repo.cleanup()
                sys.exit(1)
        # with directory_context(self.gitRepoName):
        #    if not noisy_run("git log --format=%H -1"):
        #        self.repo.cleanup()
        #        sys.exit(1)
        cvspaths = list(ftw(self.checkout.name, ignore="CVS"))
        cvsfiles = [fn[len(self.checkout.name) + 1 :] for fn in cvspaths]
        gitpaths = list(ftw(self.gitRepoName, ignore=".git"))
        gitfiles = [fn[len(self.gitRepoName) + 1 :] for fn in gitpaths]
        cvsfiles.sort()
        gitfiles.sort()
        success = True
        if cvsfiles != gitfiles:
            if success_expected:
                sys.stderr.write(preamble + "file manifests don't match.\n")
                if self.showdiffs:
                    sys.stderr.write(
                        preamble
                        + "common: %d\n" % len([f for f in gitfiles if f in cvsfiles])
                    )
                    gitspace_only = {f for f in gitfiles if not f in cvsfiles}
                    if gitspace_only:
                        sys.stderr.write(
                            preamble + "gitspace only: %s\n" % gitspace_only
                        )
                    cvs_only = {f for f in cvsfiles if not f in gitfiles}
                    if cvs_only:
                        sys.stderr.write(preamble + "CVS only: %s\n" % cvs_only)
            success = False
        common = [
            (
                path,
                path.replace(CVSCheckout.SUFFIX + "/", ConvertComparison.SUFFIX + "/"),
            )
            for path in cvspaths
            if path.replace(CVSCheckout.SUFFIX + "/", ConvertComparison.SUFFIX + "/")
            in gitpaths
        ]
        for (a, b) in common:
            if not filecmp.cmp(a, b, shallow=False):
                success = False
                if success_expected:
                    sys.stderr.write(preamble + "%s and %s are different.\n" % (a, b))
                    if self.showdiffs:
                        diff = capture_or_die("diff -u %s %s" % (a, b))
                        if self.tapify:
                            diff = "--- |\n" + diff + "...\n"
                            diff = "".join(["    " + ln for ln in diff.splitlines()])
                        sys.stdout.write(diff)
        if success:
            if not success_expected:
                sys.stderr.write(preamble + "trees unexpectedly match\n")
            elif verbose >= DEBUG_STEPS:
                sys.stderr.write(preamble + "trees matched as expected\n")
        elif not success:
            if not success_expected and verbose >= DEBUG_STEPS:
                sys.stderr.write(preamble + "trees diverged as expected\n")
        return success

    def checkall(self):
        "Check all named references - branches and tags - expecting matches."
        success = True
        for branch in self.branches:
            if branch.endswith("UNNAMED-BRANCH"):
                if verbose > 0:
                    sys.stderr.write(
                        "%s: skipping %s\n" % (os.path.basename(sys.argv[0]), branch)
                    )
            else:
                immediate = cc.compare_tree("branch", branch)
                if success and not immediate:
                    success = False
        for tag in cc.tags:
            immediate = cc.compare_tree("tag", tag)
            if success and not immediate:
                success = False
        # Messages for the failure case were shipped earlier
        if success:
            sys.stdout.write("ok - %s conversion compares clean.\n" % repo)
        return success

    # pylint: disable=no-self-use
    def command_returns(self, cmd, expected):
        seen = capture_or_die(cmd)
        succeeded = seen.strip() == expected.strip()
        if not succeeded:
            sys.stderr.write(cmd + " return was not as expected\n")

    def cleanup(self):
        self.checkout.cleanup()
        shutil.rmtree(self.gitRepoName)


# End.

# Copy of testlifter.py ends here

if __name__ == "__main__":
    import getopt

    (opts, arguments) = getopt.getopt(sys.argv[1:], "npqtvwA:")
    retain = True
    engine_opts = ""
    quiet = False
    tap_output = False
    err_leader = ""
    whole_repo = True
    for (opt, arg) in opts:
        if opt == "-v":
            verbose += 1
        elif opt == "-n":
            retain = False
        elif opt == "-p":
            engine_opts += " -p"
        elif opt == "-q":
            engine_opts += " -q"
        elif opt == "-t":
            tap_output = True
            err_leader = "not ok - "
        elif opt == "-w":
            whole_repo = True
        elif opt == "-A":
            engine_opts += " -A " + arg

    if not arguments:
        sys.stderr.write("cvsconvert: requires one or nore repo/module arguments.\n")
        sys.exit(1)
    for directory in arguments:
        if "/" in directory:
            (repo, module) = directory.split("/", 1)
        elif whole_repo or (not os.path.isdir(directory + os.sep + "CVSROOT")):
            (repo, module) = (directory, None)
        else:
            candidates = [
                sub
                for sub in os.listdir(directory)
                if sub != "CVSROOT" and os.path.isdir(os.path.join(directory, sub))
            ]
            if not candidates:
                sys.stderr.write(
                    err_leader + "cvsconvert: no modules under %s.\n" % directory
                )
                sys.exit(1)
            elif len(candidates) > 1:
                sys.stderr.write(
                    err_leader + "cvsconvert: choose one of %s\n" % candidates
                )
                sys.exit(1)
            else:
                (repo, module) = (directory, candidates[0])
                if "-q" not in engine_opts:
                    sys.stderr.write(
                        err_leader + "cvsconvert: processing %s/%s\n" % (repo, module)
                    )
        if repo == module:
            sys.stderr.write(
                err_leader
                + "cvsconvert: repo directory and module name cannot be the same.\n"
            )
            sys.exit(1)
        if not os.path.exists(repo):
            sys.stderr.write(
                err_leader + "cvsconvert: repo %s does not exist.\n" % repo
            )
            sys.exit(1)
        if os.path.realpath(os.path.abspath(".")) == os.path.realpath(
            os.path.abspath(repo)
        ):
            sys.stderr.write(
                err_leader
                + "cvsconvert: repo directory cannot be the current directory.\n"
            )
            sys.exit(1)
        cc = ConvertComparison(
            srepo=repo,
            smodule=module,
            checkout=None,
            options=engine_opts,
            showdiffs=True,
            tapify=tap_output,
        )
        if verbose == 0 and "-p" in engine_opts:
            verbose = DEBUG_STEPS
        cc.repo.retain = retain
        cc.checkall()
        cc.checkout.cleanup()
        if retain:
            sys.stderr.write(
                err_leader + "cvsconvert: conversion is in %s\n" % (cc.gitRepoName)
            )
        else:
            cc.cleanup()

# end
