#!/usr/bin/env python3
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
##########################################################################
#
# This is a collection of helper tools to get stuff done in NSS.
#

import sys
import argparse
import fnmatch
import glob
import io
import logging
import re
import subprocess
import os
import platform
import shutil
import tarfile
import tempfile
import urllib.request
import yaml
import zipfile

from hashlib import sha256

DEVNULL = open(os.devnull, "wb")
cwd = os.path.dirname(os.path.abspath(__file__))


# --- Logging setup for coverage commands -------------------------------------


def _colors_supported():
    """Return True if stderr supports ANSI color codes."""
    if os.environ.get("NO_COLOR"):
        return False
    if os.environ.get("FORCE_COLOR"):
        return True
    return hasattr(sys.stderr, "isatty") and sys.stderr.isatty()


class _ColorFormatter(logging.Formatter):
    """Formatter that adds ANSI colors based on log level."""

    RESET = "\033[0m"
    COLORS = {
        logging.CRITICAL: "\033[1;31m",  # Bold red
        logging.ERROR: "\033[31m",  # Red
        logging.WARNING: "\033[33m",  # Yellow
        logging.DEBUG: "\033[90m",  # Dark gray
    }

    def format(self, record):
        if record.levelno == logging.INFO:
            return record.getMessage()
        color = self.COLORS.get(record.levelno, "")
        return "{}{}:{} {}".format(
            color, record.levelname, self.RESET, record.getMessage()
        )


class _PlainFormatter(logging.Formatter):
    """Plain formatter without colors."""

    def format(self, record):
        if record.levelno == logging.INFO:
            return record.getMessage()
        return "{}: {}".format(record.levelname, record.getMessage())


def _setup_coverage_logger():
    """Create and configure the logger for coverage commands."""
    logger = logging.getLogger("nss.coverage")
    logger.setLevel(logging.DEBUG)
    handler = logging.StreamHandler(sys.stderr)
    handler.setLevel(logging.INFO)
    if _colors_supported():
        handler.setFormatter(_ColorFormatter())
    else:
        handler.setFormatter(_PlainFormatter())
    logger.addHandler(handler)
    return logger


cov_log = _setup_coverage_logger()

# -----------------------------------------------------------------------------


def run_tests(test=None, cycles="standard", env={}, silent=False, logfile=None):
    domsuf = os.getenv("DOMSUF", "localdomain")
    host = os.getenv("HOST", "localhost")
    env = env.copy()
    env.update({"NSS_CYCLES": cycles, "DOMSUF": domsuf, "HOST": host})
    if test is not None:
        env["NSS_TESTS"] = test
    os_env = os.environ
    os_env.update(env)
    kwargs = {"env": os_env, "cwd": os.path.join(cwd, "tests")}
    if logfile:
        kwargs["stdout"] = open(logfile, "w")
        kwargs["stderr"] = subprocess.STDOUT
    elif silent:
        kwargs["stdout"] = kwargs["stderr"] = DEVNULL
    subprocess.check_call("./all.sh", **kwargs)


class cfAction(argparse.Action):
    docker_command = None
    restorecon = None

    def __call__(self, parser, args, values, option_string=None):
        self.setDockerCommand(args)

        if values:
            files = [os.path.relpath(os.path.abspath(x), start=cwd) for x in values]
        else:
            files = self.modifiedFiles()

        # First check if we can run docker.
        try:
            with open(os.devnull, "w") as f:
                subprocess.check_call(self.docker_command + ["images"], stdout=f)
        except:
            self.docker_command = None

        if self.docker_command is None:
            print(
                "warning: running clang-format directly, which isn't guaranteed to be correct"
            )
            command = [cwd + "/automation/clang-format/run_clang_format.sh"] + files
            repr(command)
            subprocess.call(command)
            return

        files = [os.path.join("/home/worker/nss", x) for x in files]
        docker_image = "clang-format-service:latest"
        cf_docker_folder = cwd + "/automation/clang-format"

        # Build the image if necessary.
        if self.filesChanged(cf_docker_folder):
            self.buildImage(docker_image, cf_docker_folder)

        # Check if we have the docker image.
        try:
            command = self.docker_command + [
                "image",
                "inspect",
                "clang-format-service:latest",
            ]
            with open(os.devnull, "w") as f:
                subprocess.check_call(command, stdout=f)
        except:
            print("I have to build the docker image first.")
            self.buildImage(docker_image, cf_docker_folder)

        command = self.docker_command + [
            "run",
            "-v",
            cwd + ":/home/worker/nss:Z",
            "--rm",
            "-ti",
            docker_image,
        ]
        # The clang format script returns 1 if something's to do. We don't
        # care.
        subprocess.call(command + files)
        if self.restorecon is not None:
            subprocess.call([self.restorecon, "-R", cwd])

    def filesChanged(self, path):
        hash = sha256()
        for dirname, dirnames, files in os.walk(path):
            for file in files:
                with open(os.path.join(dirname, file), "rb") as f:
                    hash.update(f.read())
        chk_file = cwd + "/.chk"
        old_chk = ""
        new_chk = hash.hexdigest()
        if os.path.exists(chk_file):
            with open(chk_file) as f:
                old_chk = f.readline()
        if old_chk != new_chk:
            with open(chk_file, "w+") as f:
                f.write(new_chk)
            return True
        return False

    def buildImage(self, docker_image, cf_docker_folder):
        command = self.docker_command + ["build", "-t", docker_image, cf_docker_folder]
        subprocess.check_call(command)
        return

    def setDockerCommand(self, args):
        if platform.system() == "Linux":
            self.restorecon = shutil.which("restorecon")
        dcmd = shutil.which("docker")
        if dcmd is not None:
            self.docker_command = [dcmd]
            if not args.noroot:
                self.docker_command = ["sudo"] + self.docker_command
        else:
            self.docker_command = None

    def modifiedFiles(self):
        files = []
        if os.path.exists(os.path.join(cwd, ".hg")):
            st = subprocess.Popen(
                ["hg", "status", "-m", "-a"],
                cwd=cwd,
                stdout=subprocess.PIPE,
                universal_newlines=True,
            )
            for line in iter(st.stdout.readline, ""):
                files += [line[2:].rstrip()]
        elif os.path.exists(os.path.join(cwd, ".git")):
            st = subprocess.Popen(
                ["git", "status", "--porcelain"], cwd=cwd, stdout=subprocess.PIPE
            )
            for line in st.stdout:
                line = line.decode("utf-8").rstrip()
                if line == "":
                    pass
                elif line[1] == "M" or (
                    line[1] != "D"
                    and (
                        line[0] == "M"
                        or line[0] == "A"
                        or line[0] == "C"
                        or line[0] == "U"
                    )
                ):
                    files += [line[3:]]
                elif line[0] == "R":
                    files += [line[line.index(" -> ", beg=4) + 4 :]]
        else:
            print("Warning: neither mercurial nor git detected!")

        def isFormatted(x):
            return x[-2:] == ".c" or x[-3:] == ".cc" or x[-2:] == ".h"

        return [x for x in files if isFormatted(x)]


class buildAction(argparse.Action):

    def __call__(self, parser, args, values, option_string=None):
        subprocess.check_call([cwd + "/build.sh"] + values)


class testAction(argparse.Action):

    def __call__(self, parser, args, values, option_string=None):
        run_tests(values)


class covAction(argparse.Action):

    def runSslGtests(self, outdir):
        env = {
            "GTESTFILTER": "*",  # Prevent parallel test runs.
            "ASAN_OPTIONS": "coverage=1:coverage_dir=" + outdir,
            "NSS_DEFAULT_DB_TYPE": "sql",
            "NSS_DISABLE_UNLOAD": "1",
        }

        run_tests("ssl_gtests", env=env)

    def findSanCovFile(self, outdir):
        for file in os.listdir(outdir):
            if fnmatch.fnmatch(file, "ssl_gtest.*.sancov"):
                return os.path.join(outdir, file)

        return None

    def __call__(self, parser, args, values, option_string=None):
        outdir = args.outdir
        print("Output directory: " + outdir)

        print("\nBuild with coverage sanitizers...\n")
        sancov_args = "edge,no-prune,trace-pc-guard,trace-cmp"
        subprocess.check_call(
            [
                os.path.join(cwd, "build.sh"),
                "-c",
                "--clang",
                "--asan",
                "--enable-legacy-db",
                "--sancov=" + sancov_args,
            ]
        )

        print("\nRun ssl_gtests to get a coverage report...")
        self.runSslGtests(outdir)
        print("Done.")

        sancov_file = self.findSanCovFile(outdir)
        if not sancov_file:
            print("Couldn't find .sancov file.")
            sys.exit(1)

        symcov_file = os.path.join(outdir, "ssl_gtest.symcov")
        out = open(symcov_file, "wb")
        # Don't exit immediately on error
        symbol_retcode = subprocess.call(
            [
                "sancov",
                "-ignorelist=" + os.path.join(cwd, ".sancov-blacklist"),
                "-symbolize",
                sancov_file,
                os.path.join(cwd, "../dist/Debug/bin/ssl_gtest"),
            ],
            stdout=out,
        )
        out.close()

        print("\nCopying ssl_gtests to artifacts...")
        shutil.copyfile(
            os.path.join(cwd, "../dist/Debug/bin/ssl_gtest"),
            os.path.join(outdir, "ssl_gtest"),
        )

        print("\nCoverage report: " + symcov_file)
        if symbol_retcode > 0:
            print(
                "sancov failed to symbolize with return code {}".format(symbol_retcode)
            )
        sys.exit(symbol_retcode)


class sourceCovAction(argparse.Action):

    # Regex for filenames to exclude from coverage reports.
    IGNORE_REGEX = "gtests/google_test|lib/sqlite"
    JOB_NAME = "NSS Test Coverage"

    @staticmethod
    def _vcs():
        """Return 'hg', 'git', or None."""
        if os.path.exists(os.path.join(cwd, ".hg")):
            return "hg"
        if os.path.exists(os.path.join(cwd, ".git")):
            return "git"
        return None

    @staticmethod
    def _run_cmd(cmd):
        """Run cmd, return stripped stdout or None on failure."""
        try:
            r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=True)
            return r.stdout.strip() or None
        except (subprocess.CalledProcessError, FileNotFoundError):
            return None

    @staticmethod
    def getCommitInfo():
        """Return 'shorthash: first line of commit' for the working directory.

        Supports both Mercurial and Git.  Returns None on failure.
        """
        vcs = sourceCovAction._vcs()
        if vcs == "hg":
            return sourceCovAction._run_cmd(
                ["hg", "log", "-r", ".", "--template", "{node|short}: {desc|firstline}"]
            )
        if vcs == "git":
            return sourceCovAction._run_cmd(["git", "log", "-1", "--format=%h: %s"])
        return None

    @staticmethod
    def detectLlvmVersion():
        """Detect the major LLVM version from the clang on PATH."""
        output = sourceCovAction._run_cmd(["clang", "--version"])
        if output:
            m = re.search(r"clang version (\d+)", output)
            if m:
                return m.group(1)
        return "18"  # Fallback for CI images where clang may not be on PATH.

    _llvm_version_cached = None

    def _llvm_version(self):
        if type(self)._llvm_version_cached is None:
            type(self)._llvm_version_cached = self.detectLlvmVersion()
        return type(self)._llvm_version_cached

    def detectBaseRev(self):
        """Auto-detect the base revision for diff coverage.

        For Mercurial, returns the most recent public ancestor (i.e. the
        boundary between pushed and local draft changes).
        For Git, returns the merge-base with the upstream tracking branch,
        falling back to origin/main or origin/master.
        Returns None if detection fails.
        """
        vcs = self._vcs()
        if vcs == "hg":
            return self._run_cmd(
                [
                    "hg",
                    "log",
                    "-r",
                    "last(ancestors(.) and public())",
                    "--template",
                    "{node|short}",
                ]
            )
        if vcs == "git":
            for ref in ["@{upstream}", "origin/main", "origin/master"]:
                rev = self._run_cmd(["git", "merge-base", "HEAD", ref])
                if rev:
                    return rev
        return None

    @staticmethod
    def _cleanup_tmp_git():
        """Remove the temporary git repo."""
        shutil.rmtree(os.path.join(cwd, ".git"), ignore_errors=True)

    def runDiffCover(self, lcov_file, outdir, base_rev, fail_under=None):
        """Run diff-cover against the given base revision.

        Generates a diff, runs diff-cover on it, and reports coverage of
        changed lib/ lines.  Requires the diff_cover package to be installed.
        Skips gracefully if it is not available.

        If fail_under is set, diff-cover exits non-zero when coverage of
        changed lines is below that percentage (used by CI).
        """
        if not shutil.which("diff-cover"):
            cov_log.error("\ndiff-cover is not installed; skipping diff coverage.")
            cov_log.info("Install with: pipx install diff-cover")
            return

        if not base_rev:
            base_rev = self.detectBaseRev()
        if not base_rev:
            cov_log.error(
                "\nCould not determine base revision; " "skipping diff coverage."
            )
            cov_log.info("Use --base-rev to specify manually.")
            return

        cov_log.info(
            "\nGenerating diff coverage against base revision " "%s...\n", base_rev
        )

        is_hg = self._vcs() == "hg"
        tmp_git = False

        if is_hg:
            # diff-cover requires a real git repo.  Build a temporary one
            # with two commits (base -> current) so that diff-cover can
            # resolve source paths and compute its diff natively.
            diff_file = os.path.join(outdir, "changes.diff")
            with open(diff_file, "w") as f:
                subprocess.check_call(
                    ["hg", "diff", "--git", "-r", base_rev], cwd=cwd, stdout=f
                )

            if not os.path.exists(diff_file) or os.path.getsize(diff_file) == 0:
                cov_log.info(
                    "No changes detected against base revision; "
                    "skipping diff coverage."
                )
                return

            cov_log.info("Creating temporary git repo for diff-cover...")
            subprocess.check_call(
                ["git", "init"],
                cwd=cwd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            tmp_git = True

            # Ensure git can commit in CI where no identity is configured.
            for gcfg in [["user.email", "nss@ci"], ["user.name", "nss"]]:
                subprocess.check_call(
                    ["git", "config"] + gcfg,
                    cwd=cwd,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )

            # Exclude hg metadata from the temporary git repo.
            with open(os.path.join(cwd, ".git", "info", "exclude"), "a") as f:
                f.write(".hg\n")

            # Commit the current (post-change) working-tree state.
            subprocess.check_call(
                ["git", "add", "-A"],
                cwd=cwd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            subprocess.check_call(
                ["git", "commit", "-m", "current"],
                cwd=cwd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )

            # Create a 'base' branch and reverse-apply the diff so that
            # it represents the pre-change state.
            subprocess.check_call(
                ["git", "checkout", "-b", "base"],
                cwd=cwd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            try:
                subprocess.check_call(
                    ["git", "apply", "--reverse", diff_file],
                    cwd=cwd,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
            except subprocess.CalledProcessError:
                # Restore working tree and bail out.
                subprocess.call(
                    ["git", "checkout", "--", "."],
                    cwd=cwd,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                cov_log.error(
                    "Could not reconstruct base revision in "
                    "temporary git repo; skipping diff coverage."
                )
                self._cleanup_tmp_git()
                return

            subprocess.check_call(
                ["git", "add", "-A"],
                cwd=cwd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            subprocess.check_call(
                ["git", "commit", "-m", "base"],
                cwd=cwd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )

            # Switch back so the working tree matches the current source.
            subprocess.check_call(
                ["git", "checkout", "-"],
                cwd=cwd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            compare_branch = "base"
        else:
            # Native git repo – just check whether there are changes.
            result = subprocess.run(
                ["git", "diff", "--quiet", base_rev], cwd=cwd, capture_output=True
            )
            if result.returncode == 0:
                cov_log.info(
                    "No changes detected against base revision; "
                    "skipping diff coverage."
                )
                return
            compare_branch = base_rev

        try:
            # Save the diff as an artifact for inspection.
            diff_file = os.path.join(outdir, "changes.diff")
            with open(diff_file, "w") as f:
                subprocess.call(
                    ["git", "diff", compare_branch + "..HEAD"], cwd=cwd, stdout=f
                )

            # Run diff-cover.
            diff_html = os.path.join(outdir, "diff_coverage.html")
            cmd = [
                "diff-cover",
                lcov_file,
                "--compare-branch",
                compare_branch,
                "--diff-range-notation",
                "..",
                "--format",
                "html:" + diff_html,
                "--include=lib/**",
            ]
            if fail_under is not None:
                cmd.append("--fail-under={}".format(fail_under))
            rc = subprocess.call(cmd, stderr=subprocess.STDOUT)
            if rc == 1 and fail_under is not None:
                cov_log.error("Coverage of changed lines is below %d%%.", fail_under)
                sys.exit(rc)
            elif rc != 0:
                cov_log.error("diff-cover exited with code %d.", rc)
                if fail_under is not None:
                    sys.exit(rc)
            cov_log.info("Diff coverage report: %s", diff_html)
            cov_log.info("Diff file:            %s", diff_file)
        finally:
            if tmp_git:
                self._cleanup_tmp_git()

    def generateReports(self, outdir, profraw_dir, args):
        """Merge profiles, generate coverage reports, and run diff-cover."""
        # Merge raw profiles.
        cov_log.info("\nMerging profile data...\n")
        profraw_files = glob.glob(os.path.join(profraw_dir, "*.profraw"))
        if not profraw_files:
            cov_log.error("No .profraw files found in %s", profraw_dir)
            sys.exit(1)

        profdata = os.path.join(outdir, "merged.profdata")
        filelist = os.path.join(outdir, "profraw_files.txt")
        with open(filelist, "w") as f:
            f.write("\n".join(profraw_files) + "\n")
        try:
            subprocess.check_call(
                [
                    "llvm-profdata-" + self._llvm_version(),
                    "merge",
                    "-sparse",
                    "-f",
                    filelist,
                    "-o",
                    profdata,
                ]
            )
        finally:
            os.unlink(filelist)

        # Discover instrumented objects.
        objects = self.findObjects()
        if not objects:
            cov_log.error("No instrumented objects found.")
            sys.exit(1)
        cov_log.info("Found %d instrumented objects.", len(objects))

        # Scope reports to lib/ only.
        lib_dir = os.path.join(cwd, "lib")

        # Build a descriptive title for the HTML report.
        commit_info = self.getCommitInfo()
        if commit_info:
            title = "{} - {}".format(self.JOB_NAME, commit_info)
        else:
            title = self.JOB_NAME

        # Generate HTML report.
        cov_log.info("\nGenerating HTML coverage report...\n")
        html_dir = os.path.join(outdir, "coverage_html")
        self.runLlvmCov(
            "show",
            objects,
            profdata,
            extra_args=[
                "--format=html",
                "--output-dir=" + html_dir,
                "--show-directory-coverage",
                "--project-title=" + title,
                lib_dir,
            ],
        )

        # Generate LCOV report.
        cov_log.info("Generating LCOV report...\n")
        lcov_file = os.path.join(outdir, "coverage.lcov")
        self.runLlvmCov(
            "export",
            objects,
            profdata,
            outfile=lcov_file,
            extra_args=["--format=lcov", lib_dir],
        )

        # Rewrite LCOV source paths from absolute to repo-relative so that
        # diff-cover can match them against git diff paths.
        prefix = cwd + os.sep
        with open(lcov_file, "r") as f:
            lcov_data = f.read()
        lcov_data = lcov_data.replace("SF:" + prefix, "SF:")
        with open(lcov_file, "w") as f:
            f.write(lcov_data)

        cov_log.info("Coverage LCOV data:   %s", lcov_file)

        # Clean up raw profile data now that reports have been generated.
        shutil.rmtree(profraw_dir)

        # Bundle large directories into zip archives to reduce artifact
        # clutter in CI.
        cov_log.info("\nBundling artifacts...\n")

        # Zip coverage HTML report.
        html_zip = os.path.join(outdir, "full_coverage_html_report.zip")
        with zipfile.ZipFile(html_zip, "w", zipfile.ZIP_DEFLATED) as zf:
            for root, _dirs, files in os.walk(html_dir):
                for fname in files:
                    fpath = os.path.join(root, fname)
                    zf.write(fpath, os.path.relpath(fpath, outdir))
        shutil.rmtree(html_dir)
        cov_log.info("Coverage HTML zip:    %s", html_zip)

        # Diff coverage (optional).
        base_rev = getattr(args, "base_rev", None)
        fail_under = getattr(args, "diff_fail_under", None)
        self.runDiffCover(lcov_file, outdir, base_rev, fail_under)

    def findDistDir(self):
        """Find the dist output directory via dist/latest."""
        latest = os.path.join(cwd, "..", "dist", "latest")
        with open(latest) as f:
            objdir = f.read().strip()
        return os.path.join(cwd, "..", "dist", objdir)

    @staticmethod
    def _is_elf(path):
        """Check whether a file is an ELF binary."""
        try:
            with open(path, "rb") as f:
                return f.read(4) == b"\x7fELF"
        except (IOError, OSError):
            return False

    def _find_shared_libs(self):
        """Find instrumented shared libraries in the dist lib directory."""
        dist_dir = self.findDistDir()
        return glob.glob(os.path.join(dist_dir, "lib", "*.so"))

    def findObjects(self):
        """Find instrumented shared libraries and executables."""
        objects = self._find_shared_libs()
        bin_dir = os.path.join(self.findDistDir(), "bin")
        # ELF executables (skip scripts like nspr-config).
        objects += [
            os.path.join(bin_dir, f)
            for f in os.listdir(bin_dir)
            if os.path.isfile(os.path.join(bin_dir, f))
            and self._is_elf(os.path.join(bin_dir, f))
        ]
        return objects

    def runLlvmCov(self, command, objects, profdata, outfile=None, extra_args=None):
        """Run an llvm-cov subcommand with the given objects and profile."""
        obj_args = [a for obj in objects[1:] for a in ("--object", obj)]
        cmd = (
            [
                "llvm-cov-" + self._llvm_version(),
                command,
                "--instr-profile=" + profdata,
                "--ignore-filename-regex=" + self.IGNORE_REGEX,
                objects[0],
            ]
            + obj_args
            + (extra_args or [])
        )

        if outfile:
            with open(outfile, "w") as f:
                subprocess.check_call(cmd, stdout=f)
        else:
            subprocess.check_call(cmd)

    def __call__(self, parser, args, values, option_string=None):
        outdir = os.path.abspath(args.outdir)
        profraw_dir = os.path.join(outdir, "profraw")
        os.makedirs(profraw_dir, exist_ok=True)
        cov_log.info("Output directory: %s", outdir)

        # Build with source-based coverage instrumentation.
        cov_log.info("\nBuild with source-based coverage...\n")
        subprocess.check_call(
            [os.path.join(cwd, "build.sh"), "-c", "--clang", "--opt", "--sourcecov"]
        )

        # Run test suites.  If --test was given, run only that suite;
        # otherwise run all suites (uses defaults from tests/all.sh).
        # Failures are logged but do not abort — we still want coverage
        # data from whichever tests did execute.
        test_suite = getattr(args, "test_suite", None)
        if test_suite:
            cov_log.info("\nRunning test suite: %s...\n", test_suite)
        else:
            cov_log.info("\nRunning all test suites...\n")
        env = {
            "LLVM_PROFILE_FILE": os.path.join(profraw_dir, "%p-%m.profraw"),
            "GTESTFILTER": "*",  # Prevent parallel test runs.
            "NSS_DEFAULT_DB_TYPE": "sql",
            "NSS_DISABLE_UNLOAD": "1",
        }
        try:
            run_tests(test=test_suite, cycles="standard", env=env)
            cov_log.info("Done running tests.")
        except subprocess.CalledProcessError as e:
            cov_log.warning(
                "Test suite exited with code %d; "
                "continuing to generate coverage reports.",
                e.returncode,
            )

        self.generateReports(outdir, profraw_dir, args)


class fuzzCovAction(sourceCovAction):

    IGNORE_REGEX = "fuzz/targets|lib/sqlite"
    JOB_NAME = "NSS Fuzz Coverage"
    GCS_CORPUS_URL = (
        "https://storage.googleapis.com/"
        "nss-backup.clusterfuzz-external.appspot.com/"
        "corpus/libFuzzer/nss_{corpus}/public.zip"
    )

    def _find_fuzz_binaries(self):
        """Find nssfuzz-* binaries in the dist bin directory."""
        bin_dir = os.path.join(self.findDistDir(), "bin")
        return [
            f
            for f in glob.glob(os.path.join(bin_dir, "nssfuzz-*"))
            if os.path.isfile(f)
        ]

    def findObjects(self):
        """Find instrumented shared libraries and fuzz binaries."""
        return self._find_shared_libs() + self._find_fuzz_binaries()

    def discoverTargets(self):
        """Find fuzz target binaries in the build output."""
        return {
            os.path.basename(f).replace("nssfuzz-", "", 1): f
            for f in sorted(self._find_fuzz_binaries())
        }

    def getCorpusName(self, target):
        """Determine the base corpus name for a target.

        Targets with a -no_fuzzer_mode suffix share the same corpus
        as their base target (e.g. pkcs12-no_fuzzer_mode uses pkcs12).
        """
        return target.removesuffix("-no_fuzzer_mode")

    def downloadCorpus(self, corpus, corpus_dir):
        """Download corpus from GCS if the local directory is empty."""
        if os.listdir(corpus_dir):
            cov_log.info(
                "  Using existing local corpus (%d files)", len(os.listdir(corpus_dir))
            )
            return

        url = self.GCS_CORPUS_URL.format(corpus=corpus)
        zip_path = os.path.join(corpus_dir, "public.zip")
        cov_log.info("  Downloading corpus from GCS...")
        try:
            urllib.request.urlretrieve(url, zip_path)
            with zipfile.ZipFile(zip_path, "r") as z:
                z.extractall(corpus_dir)
        except Exception as e:
            cov_log.warning("Could not download corpus for %s: %s", corpus, e)
        finally:
            if os.path.exists(zip_path):
                os.unlink(zip_path)

    def getTargetOptions(self, corpus):
        """Parse libFuzzer options for a target."""
        options_file = os.path.join(cwd, "fuzz", "options", corpus + ".options")
        if not os.path.isfile(options_file):
            return []

        result = subprocess.run(
            [
                sys.executable,
                os.path.join(cwd, "fuzz", "config", "libfuzzer_options.py"),
                options_file,
            ],
            capture_output=True,
            text=True,
            check=True,
        )
        return [opt for opt in result.stdout.strip().split("\n") if opt]

    def __call__(self, parser, args, values, option_string=None):
        outdir = os.path.abspath(args.outdir)
        max_time = args.max_total_time
        target_filter = None
        if args.targets:
            target_filter = set(args.targets.split(","))
        skip_build = args.skip_build
        skip_download = args.skip_download

        profraw_dir = os.path.join(outdir, "profraw")
        os.makedirs(profraw_dir, exist_ok=True)
        cov_log.info("Output directory: %s", outdir)

        # Build with source-based coverage + fuzzing instrumentation.
        if not skip_build:
            cov_log.info("\nBuild with source-based coverage + fuzzing...\n")
            subprocess.check_call(
                [
                    os.path.join(cwd, "build.sh"),
                    "-c",
                    "--clang",
                    "--sourcecov",
                    "--fuzz",
                    "--disable-tests",
                ]
            )
        else:
            cov_log.info("\nSkipping build (--skip-build).\n")

        # Set up profile data collection.
        os.environ["LLVM_PROFILE_FILE"] = os.path.join(profraw_dir, "%p-%m.profraw")

        # Discover fuzz targets.
        targets = self.discoverTargets()
        if not targets:
            cov_log.error("No fuzz targets found.")
            sys.exit(1)

        if target_filter:
            unknown = target_filter - set(targets.keys())
            if unknown:
                cov_log.warning("Unknown target(s): %s", ", ".join(sorted(unknown)))
            targets = {k: v for k, v in targets.items() if k in target_filter}
            if not targets:
                cov_log.error("None of the specified targets were found.")
                sys.exit(1)

        cov_log.info(
            "Found %d fuzz target(s): %s",
            len(targets),
            ", ".join(sorted(targets.keys())),
        )

        # Run each fuzz target.
        fuzz_failures = 0
        for target, binary in sorted(targets.items()):
            corpus = self.getCorpusName(target)
            corpus_dir = os.path.join(cwd, "fuzz", "corpus", corpus)
            os.makedirs(corpus_dir, exist_ok=True)

            cov_log.info("\n=== Fuzz target: %s (corpus: %s) ===", target, corpus)

            if not skip_download:
                self.downloadCorpus(corpus, corpus_dir)

            options = self.getTargetOptions(target)

            cmd = (
                [binary, corpus_dir]
                + options
                + [
                    "-max_total_time=" + str(max_time),
                    "-print_final_stats=1",
                    "-close_fd_mask=3",
                    "-verbosity=0",
                    "-fork=8",
                ]
            )
            cov_log.info("Running: %s", " ".join(cmd))
            try:
                subprocess.check_call(cmd)
            except subprocess.CalledProcessError as e:
                cov_log.warning(
                    "Fuzz target %s failed (exit code %d); " "continuing.",
                    target,
                    e.returncode,
                )
                fuzz_failures += 1

        if fuzz_failures > 0:
            cov_log.warning("\n%d fuzz target(s) had failures.", fuzz_failures)

        self.generateReports(outdir, profraw_dir, args)


class clangTidyAction(argparse.Action):

    def __call__(self, parser, args, values, option_string=None):
        script = os.path.join(cwd, "automation", "clang-tidy", "run_clang_tidy.py")

        cmd = [
            sys.executable,
            script,
            "--build",
            "--include",
            "lib/",
            "--include",
            "gtests/",
            "--include",
            "cpputil/",
        ]

        if args.diff or args.diff_base:
            cmd.append("--diff")
        if args.diff_base:
            cmd += ["--diff-base", args.diff_base]
        if args.fix:
            cmd.append("--fix")
        if args.checks:
            cmd += ["--checks", args.checks]
        if args.fail_on_warnings:
            cmd.append("--fail-on-warnings")

        if values:
            extensions = {".c", ".cc", ".cpp", ".cxx"}
            files = []
            for v in values:
                p = os.path.abspath(v)
                if os.path.isdir(p):
                    for root, _, names in os.walk(p):
                        for name in names:
                            if os.path.splitext(name)[1] in extensions:
                                files.append(os.path.join(root, name))
                else:
                    files.append(p)
            cmd += [os.path.relpath(f, start=cwd) for f in files]

        try:
            subprocess.check_call(cmd, cwd=cwd)
        except subprocess.CalledProcessError as e:
            sys.exit(e.returncode)


class docLintAction(argparse.Action):

    def __call__(self, parser, args, values, option_string=None):
        env = os.environ.copy()
        env["VCS_PATH"] = os.path.dirname(cwd)
        script = os.path.join(
            cwd, "automation", "taskcluster", "scripts", "check_doc_lint.sh"
        )
        try:
            subprocess.check_call([script], env=env)
        except subprocess.CalledProcessError as e:
            print(
                "doc-lint failed with exit status {}".format(e.returncode),
                file=sys.stderr,
            )
            sys.exit(e.returncode)


def _tc_try_options():
    """Read valid try syntax options from taskcluster config files.

    Returns (platforms, tests, tools) as sorted lists of valid token strings.
    """
    tc = os.path.join(cwd, "taskcluster")

    # Platforms: the build_platform values from the build kind YAMLs are the
    # canonical try selectors (matched directly); PLATFORM_ALIASES keys in
    # target_tasks.py add the build-type variant selectors (e.g. linux-x64-make)
    # that map onto them. The valid selectors are the union of the two.
    aliases = {}
    target_tasks_path = os.path.join(tc, "nss_taskgraph", "target_tasks.py")
    in_aliases = False
    with open(target_tasks_path) as f:
        for line in f:
            if "PLATFORM_ALIASES" in line and "{" in line:
                in_aliases = True
                continue
            if in_aliases:
                if "}" in line:
                    break
                m = re.match(r'\s*"([^"]+)":\s*"([^"]+)"', line)
                if m:
                    aliases[m.group(1)] = m.group(2)

    build_platforms = set()
    for yml_path in sorted(glob.glob(os.path.join(tc, "kinds", "build", "*.yml"))):
        with open(yml_path) as f:
            data = yaml.safe_load(f)
        for key in data:
            if isinstance(key, str) and "/" in key:
                build_platforms.add(key.split("/")[0])

    platforms = sorted(set(build_platforms) | set(aliases.keys()))

    # Tests: walk test kind YAMLs; symbols like SSL(standard) contribute their
    # group name ("ssl") as the user-visible token; ungrouped symbols (Gtest,
    # MPI, …) contribute their lowercased value directly.
    # kind.yml stores tasks under a 'tasks' key; tasks-from files (e.g.
    # cipher.yml) store them at the top level alongside 'task-defaults'.
    _NON_TASK_KEYS = {
        "transforms",
        "kind-dependencies",
        "task-defaults",
        "tasks-from",
        "loader",
    }
    groups = set()
    syms = set()
    for yml_path in sorted(glob.glob(os.path.join(tc, "kinds", "test", "*.yml"))):
        with open(yml_path) as f:
            data = yaml.safe_load(f)
        if "tasks" in data:
            task_items = data["tasks"].items()
        else:
            task_items = ((k, v) for k, v in data.items() if k not in _NON_TASK_KEYS)
        for _task_key, task_val in task_items:
            if not isinstance(task_val, dict):
                continue
            sym = (task_val.get("treeherder") or {}).get("symbol", "")
            if not sym:
                continue
            m = re.match(r"^([A-Za-z][^(]*)\(", sym)
            if m:
                groups.add(m.group(1).lower())
            else:
                syms.add(sym.lower())

    tests = sorted(groups) + sorted(syms)

    # Tools: lowercased treeherder symbols from the tools kind YAML.
    tools_yml = os.path.join(tc, "kinds", "tools", "kind.yml")
    with open(tools_yml) as f:
        data = yaml.safe_load(f)
    tools = sorted(
        v["treeherder"]["symbol"].lower()
        for v in data.get("tasks", {}).values()
        if isinstance(v, dict) and "treeherder" in v
    )

    return platforms, tests, tools


class tryAction(argparse.Action):

    @staticmethod
    def _hg_email():
        """Return the user's hg email from ui.username, or None."""
        result = subprocess.run(
            ["hg", "config", "ui.username"], cwd=cwd, capture_output=True, text=True
        )
        if result.returncode != 0:
            return None
        username = result.stdout.strip()
        m = re.search(r"<([^>]+)>", username)
        return m.group(1) if m else (username or None)

    def __call__(self, parser, args, values, option_string=None):
        # argparse REMAINDER doesn't reliably capture arguments that begin
        # with '-'; read sys.argv directly instead.  sys.argv[1] is 'try'.
        values = sys.argv[2:]
        if values and values[0] == "--":
            values = values[1:]

        if not values:
            parser.print_help()
            sys.exit(0)

        result = subprocess.run(
            ["hg", "status", "-mard"], cwd=cwd, capture_output=True, text=True
        )
        if result.returncode != 0:
            print("Error: could not run hg status", file=sys.stderr)
            sys.exit(1)
        if result.stdout.strip():
            print("Please commit unstaged changes", file=sys.stderr)
            sys.exit(1)

        path_result = subprocess.run(
            ["hg", "path", "nss-try"], cwd=cwd, capture_output=True, text=True
        )
        if (
            path_result.returncode != 0
            or "hg.mozilla.org/projects/nss-try" not in path_result.stdout
        ):
            email = self._hg_email()
            url = (
                "ssh://{}@hg.mozilla.org/projects/nss-try".format(email)
                if email
                else "ssh://hg.mozilla.org/projects/nss-try"
            )
            print("You need to set up the nss-try path.", file=sys.stderr)
            print("Add the following to .hg/hgrc or ~/.hgrc:", file=sys.stderr)
            print("", file=sys.stderr)
            print("  [paths]", file=sys.stderr)
            print("  nss-try = {}".format(url), file=sys.stderr)
            print("", file=sys.stderr)
            print(
                "See https://wiki.mozilla.org/NSS:TryServer for SSH key setup.",
                file=sys.stderr,
            )
            sys.exit(1)

        trysyntax = "try: " + " ".join(values)
        print("Try syntax:")
        print("  {}".format(trysyntax))
        print()

        root_result = subprocess.run(
            ["hg", "root"], cwd=cwd, capture_output=True, text=True, check=True
        )
        tryfile = os.path.join(root_result.stdout.strip(), ".try")

        with open(tryfile, "w") as f:
            f.write(trysyntax + "\n")

        commit_suffix = os.environ.get("COMMIT_SUFFIX", "")
        commit_msg = "{} {}".format(trysyntax, commit_suffix).strip()

        try:
            subprocess.check_call(["hg", "add", tryfile])
            subprocess.check_call(["hg", "commit", "-m", commit_msg, tryfile])
        except subprocess.CalledProcessError:
            print("Error creating try commit", file=sys.stderr)
            if os.path.exists(tryfile):
                os.unlink(tryfile)
            sys.exit(1)

        print("Pushing to nss-try...")
        try:
            push_result = subprocess.run(
                ["hg", "push", "--new-branch", "--force", "-r", ".", "nss-try"]
            )
            if push_result.returncode == 0:
                rev = subprocess.run(
                    ["hg", "id", "--id"],
                    cwd=cwd,
                    capture_output=True,
                    text=True,
                    check=True,
                ).stdout.strip()
                print("Pushed {} to nss-try. Find it on Treeherder:".format(rev))
                print()
                print(
                    "  https://treeherder.mozilla.org/#/jobs?repo=nss-try&revision={}".format(
                        rev
                    )
                )
                print()
        finally:
            print("Cleaning up...")
            if subprocess.call(["hg", "strip", "-r", "."]) != 0:
                print(
                    "Warning: failed to strip try commit. "
                    "Run 'hg strip -r .' to clean up manually.",
                    file=sys.stderr,
                )

        if os.path.exists(tryfile):
            print(
                "Something went wrong! Watch out, {} still exists.".format(tryfile),
                file=sys.stderr,
            )
            sys.exit(1)
        # Exit without handing control back to the argument parser. All of the
        # arguments have been interpreted as try syntax.
        sys.exit(0)


class commandsAction(argparse.Action):
    commands = []

    def __call__(self, parser, args, values, option_string=None):
        for c in commandsAction.commands:
            print(c)


def parse_arguments():
    parser = argparse.ArgumentParser(
        description="NSS helper script. "
        + "Make sure to separate sub-command arguments with --."
    )
    subparsers = parser.add_subparsers()

    parser_build = subparsers.add_parser(
        "build", help="All arguments are passed to build.sh"
    )
    parser_build.add_argument(
        "build_args", nargs="*", help="build arguments", action=buildAction
    )

    parser_cf = subparsers.add_parser(
        "clang-format",
        help="""
        Run clang-format.

        By default this runs against any files that you have modified.  If
        there are no modified files, it checks everything.
        """,
    )
    parser_cf.add_argument(
        "--noroot",
        help="On linux, suppress the use of 'sudo' for running docker.",
        action="store_true",
    )
    parser_cf.add_argument(
        "<file/dir>",
        nargs="*",
        help="Specify files or directories to run clang-format on",
        action=cfAction,
    )

    parser_test = subparsers.add_parser("tests", help="Run tests through tests/all.sh.")
    tests = [
        "cipher",
        "lowhash",
        "chains",
        "cert",
        "dbtests",
        "tools",
        "fips",
        "sdr",
        "smime",
        "ssl",
        "ocsp",
        "merge",
        "pkits",
        "ec",
        "gtests",
        "ssl_gtests",
        "bogo",
        "interop",
        "policy",
        "mpi",
        "cipher-noaes",
        "cipher-noavx",
        "cipher-nopclmul",
        "cipher-nosha",
        "cipher-nosse41",
        "cipher-nossse3",
    ]
    parser_test.add_argument(
        "test",
        nargs="?",
        choices=tests,
        default=None,
        help="Test suite to run (default: all)",
        action=testAction,
    )

    parser_cov = subparsers.add_parser("coverage", help="Generate coverage report")
    cov_modules = ["ssl_gtests"]
    parser_cov.add_argument(
        "--outdir",
        help="Output directory for coverage report data.",
        default=tempfile.mkdtemp(),
    )
    parser_cov.add_argument(
        "module",
        choices=cov_modules,
        help="Available coverage modules",
        action=covAction,
    )

    parser_tcov = subparsers.add_parser(
        "test-coverage",
        help="Generate source-based test coverage report " "(HTML, LCOV, summary)",
    )
    parser_tcov.add_argument(
        "--outdir",
        help="Output directory for coverage report data.",
        default=tempfile.mkdtemp(),
    )
    parser_tcov.add_argument(
        "--base-rev", help="Base revision for diff coverage (auto-detected if omitted)"
    )
    parser_tcov.add_argument(
        "--diff-fail-under",
        type=int,
        default=90,
        help="Fail if diff coverage is below this percentage (e.g. 90)",
    )
    parser_tcov.add_argument(
        "--test",
        choices=tests,
        dest="test_suite",
        help="Run only the specified test suite (default: all)",
    )
    parser_tcov.add_argument(
        "action",
        nargs="?",
        default="run",
        help="Run test coverage (default action)",
        action=sourceCovAction,
    )

    parser_fcov = subparsers.add_parser(
        "fuzz-coverage",
        help="Generate source-based fuzz coverage report " "(HTML, LCOV, summary)",
    )
    parser_fcov.add_argument(
        "--outdir",
        help="Output directory for coverage report data.",
        default=tempfile.mkdtemp(),
    )
    parser_fcov.add_argument(
        "--max-total-time",
        type=int,
        default=10,
        help="Maximum time in seconds to fuzz each target (default: 10)",
    )
    parser_fcov.add_argument(
        "--targets", help="Comma-separated list of fuzz target names to run"
    )
    parser_fcov.add_argument(
        "--skip-build",
        action="store_true",
        help="Skip the build step (use existing build)",
    )
    parser_fcov.add_argument(
        "--skip-download",
        action="store_true",
        help="Skip corpus download (use only local corpus data)",
    )
    parser_fcov.add_argument(
        "--base-rev", help="Base revision for diff coverage (auto-detected if omitted)"
    )
    parser_fcov.add_argument(
        "--diff-fail-under",
        type=int,
        default=None,
        help="Fail if diff coverage is below this percentage (e.g. 90)",
    )
    parser_fcov.add_argument(
        "action",
        nargs="?",
        default="run",
        help="Run fuzz coverage (default action)",
        action=fuzzCovAction,
    )

    parser_ct = subparsers.add_parser(
        "clang-tidy",
        help="Run clang-tidy static analysis on lib/, gtests/, and cpputil/ source files.",
    )
    parser_ct.add_argument(
        "--diff",
        action="store_true",
        help="Only check files modified in the working directory",
    )
    parser_ct.add_argument(
        "--diff-base",
        help="Base revision for --diff (default: working-directory changes)",
    )
    parser_ct.add_argument(
        "--fix", action="store_true", help="Apply suggested fixes in-place"
    )
    parser_ct.add_argument(
        "--checks", help="Override the checks glob (passed to clang-tidy --checks)"
    )
    parser_ct.add_argument(
        "--fail-on-warnings", action="store_true", help="Treat all warnings as errors"
    )
    parser_ct.add_argument(
        "<file>",
        nargs="*",
        help="Specific files to check (default: all lib/ files)",
        action=clangTidyAction,
    )

    parser_doc_lint = subparsers.add_parser(
        "doc-lint", help="Check RST documentation for lint errors using sphinx-build."
    )
    parser_doc_lint.add_argument("doc_lint", nargs="*", action=docLintAction)

    try:
        tc_platforms, tc_tests, tc_tools = _tc_try_options()
    except Exception:
        tc_platforms, tc_tests, tc_tools = [], [], []

    def _wrap(items, width=60):
        lines, cur = [], []
        cur_len = 0
        for item in items:
            added = len(item) + (1 if cur else 0)  # +1 for comma
            if cur and cur_len + added > width:
                lines.append(",".join(cur))
                cur, cur_len = [item], len(item)
            else:
                cur.append(item)
                cur_len += added
        if cur:
            lines.append(",".join(cur))
        return lines

    indent = " " * 21

    def _fmt(items):
        if not items:
            return "all"
        lines = _wrap(items)
        return (",\n" + indent).join(lines)

    try_description = (
        "Push a try run to hg.mozilla.org/projects/nss-try.\n\n"
        "Try syntax flags:\n"
        "  -b d|o             Build type: d=debug, o=opt (default: do)\n"
        "  -p PLATFORM,...    {p} | all | none (default: all)\n"
        "  -u TEST,...        {u} | all | none (default: none)\n"
        "  -t TOOL,...        {t} | all | none (default: none)\n"
        "  -e all             Enable extra builds (default: disabled)\n"
        "  --nspr-patch       Apply nspr.patch from the repo root before building\n"
    ).format(p=_fmt(tc_platforms), u=_fmt(tc_tests), t=_fmt(tc_tools))

    parser_try = subparsers.add_parser(
        "try",
        help="Push to nss-try server.",
        description=try_description,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_try.add_argument(
        "try_args", nargs=argparse.REMAINDER, help=argparse.SUPPRESS, action=tryAction
    )

    parser_commands = subparsers.add_parser("mach-completion", help="list commands")
    parser_commands.add_argument("mach-completion", nargs="*", action=commandsAction)

    commandsAction.commands = [c for c in subparsers.choices]
    return parser.parse_args()


def main():
    parse_arguments()


if __name__ == "__main__":
    main()
