#! /usr/bin/env python3
# pyright: strict

# --------------------------------------------------------------------
# --- Cachegrind's annotator.                       cg_annotate.in ---
# --------------------------------------------------------------------

# This file is part of Cachegrind, a Valgrind tool for cache
# profiling programs.
#
# Copyright (C) 2002-2023 Nicholas Nethercote
#    njn@valgrind.org
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation; either version 2 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <http://www.gnu.org/licenses/>.
#
# The GNU General Public License is contained in the file COPYING.

"""
This script reads Cachegrind output files and produces human-readable reports.
"""

# Use `make pyann` to "build" this script with `auxprogs/pybuild.rs` every time
# it is changed. This runs the formatters, type-checkers, and linters on
# `cg_annotate.in` and then generates `cg_annotate`.


from __future__ import annotations

import os
import re
import sys
from argparse import ArgumentParser, BooleanOptionalAction, Namespace
from collections import defaultdict
from typing import DefaultDict, NewType, NoReturn, TextIO


class Args(Namespace):
    """
    A typed wrapper for parsed args.

    None of these fields are modified after arg parsing finishes.
    """

    show: list[str]
    sort: list[str]
    threshold: float  # a percentage
    show_percs: bool
    annotate: bool
    context: int
    include: list[str]
    cgout_filename: list[str]

    @staticmethod
    def parse() -> Args:
        def comma_separated_list(values: str) -> list[str]:
            return values.split(",")

        def threshold(n: str) -> float:
            f = float(n)
            if 0 <= f <= 20:
                return f
            raise ValueError

        def add_bool_argument(
            p: ArgumentParser, new_name: str, old_name: str, help_: str
        ) -> None:
            """
            Add a bool argument that defaults to true.

            Supports these forms: `--foo`, `--no-foo`, `--foo=yes`, `--foo=no`.
            The latter two were the forms supported by the old Perl version of
            `cg_annotate`, and are now deprecated.
            """
            new_flag = "--" + new_name
            old_flag = "--" + old_name
            dest = new_name.replace("-", "_")

            # Note: the default value is always printed with `BooleanOptionalAction`,
            # due to an argparse bug: https://github.com/python/cpython/issues/83137.
            p.add_argument(
                new_flag,
                default=True,
                action=BooleanOptionalAction,
                help=help_,
            )
            p.add_argument(
                f"{old_flag}=yes",
                dest=dest,
                action="store_true",
                help=f"(deprecated) same as --{new_name}",
            )
            p.add_argument(
                f"{old_flag}=no",
                dest=dest,
                action="store_false",
                help=f"(deprecated) same as --no-{new_name}",
            )

        p = ArgumentParser(description="Process a Cachegrind output file.")

        p.add_argument("--version", action="version", version="%(prog)s-3.21.0.GIT")

        p.add_argument(
            "--show",
            type=comma_separated_list,
            metavar="A,B,C",
            help="only show figures for events A,B,C (default: all events)",
        )

        p.add_argument(
            "--sort",
            type=comma_separated_list,
            metavar="A,B,C",
            help="sort functions by events A,B,C (default: event column order)",
        )

        p.add_argument(
            "--threshold",
            type=threshold,
            default=0.1,
            metavar="N:[0,20]",
            help="only show functions with more than N%% of primary sort event "
            "counts (default: %(default)s)",
        )
        add_bool_argument(
            p,
            "show-percs",
            "show-percs",
            "show a percentage for each non-zero count",
        )
        add_bool_argument(
            p,
            "annotate",
            "auto",
            "annotate all source files containing functions that reached the "
            "event count threshold",
        )
        p.add_argument(
            "--context",
            type=int,
            default=8,
            metavar="N",
            help="print N lines of context before and after annotated lines "
            "(default: %(default)s)",
        )
        p.add_argument(
            "-I",
            "--include",
            action="append",
            default=[],
            metavar="D",
            help="add D to the list of searched source file directories",
        )
        p.add_argument(
            "cgout_filename",
            nargs=1,
            metavar="cachegrind-out-file",
            help="file produced by Cachegrind",
        )

        return p.parse_args(namespace=Args())


# Args are stored in a global for easy access.
args = Args.parse()


# A single instance of this class is constructed, from `args` and the `events:`
# line in the cgout file.
class Events:
    # The event names.
    events: list[str]

    # The order in which we must traverse events for --show. Can be shorter
    # than `events`.
    show_events: list[str]

    # Like `show_events`, but indices into `events`, rather than names.
    show_indices: list[int]

    # The order in which we must traverse events for --sort. Can be shorter
    # than `events`.
    sort_events: list[str]

    # Like `sort_events`, but indices into `events`, rather than names.
    sort_indices: list[int]

    def __init__(self, text: str) -> None:
        self.events = text.split()
        self.num_events = len(self.events)

        # A temporary dict mapping events to indices, [0, n-1].
        event_indices = {event: n for n, event in enumerate(self.events)}

        # If --show is given, check it is valid. If --show is not given,
        # default to all events in the standard order.
        if args.show:
            for event in args.show:
                if event not in event_indices:
                    die(f"--show event `{event}` did not appear in `events:` line")
            self.show_events = args.show
        else:
            self.show_events = self.events

        self.show_indices = [event_indices[event] for event in self.show_events]

        # Likewise for --sort.
        if args.sort:
            for event in args.sort:
                if event not in event_indices:
                    die(f"--sort event `{event}` did not appear in `events:` line")
            self.sort_events = args.sort
        else:
            self.sort_events = self.events

        self.sort_indices = [event_indices[event] for event in self.sort_events]

    def mk_cc(self, text: str) -> Cc:
        """Raises a `ValueError` exception on syntax error."""
        # This is slightly faster than a list comprehension.
        counts = list(map(int, text.split()))

        if len(counts) == self.num_events:
            pass
        elif len(counts) < self.num_events:
            # Add zeroes at the end for any missing numbers.
            counts.extend([0] * (self.num_events - len(counts)))
        else:
            raise ValueError

        return Cc(counts)

    def mk_empty_cc(self) -> Cc:
        # This is much faster than a list comprehension.
        return Cc([0] * self.num_events)


class Cc:
    """
    This is a dumb container for counts.

    It doesn't know anything about events, i.e. what each count means. It can
    do basic operations like `__iadd__` and `__eq__`, and anything more must be
    done elsewhere. `Events.mk_cc` and `Events.mk_empty_cc` are used for
    construction.
    """

    # Always the same length as `Events.events`.
    counts: list[int]

    def __init__(self, counts: list[int]) -> None:
        self.counts = counts

    def __repr__(self) -> str:
        return str(self.counts)

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Cc):
            return NotImplemented
        return self.counts == other.counts

    def __iadd__(self, other: Cc) -> Cc:
        for i, other_count in enumerate(other.counts):
            self.counts[i] += other_count
        return self


# A paired filename and function name.
Flfn = NewType("Flfn", tuple[str, str])

# Per-function CCs.
DictFlfnCc = DefaultDict[Flfn, Cc]

# Per-line CCs, organised by filename and line number.
DictLineCc = DefaultDict[int, Cc]
DictFlDictLineCc = DefaultDict[str, DictLineCc]


def die(msg: str) -> NoReturn:
    print("cg_annotate: error:", msg, file=sys.stderr)
    sys.exit(1)


def read_cgout_file() -> tuple[str, str, Events, DictFlfnCc, DictFlDictLineCc, Cc]:
    # The file format is described in Cachegrind's manual.
    try:
        cgout_file = open(args.cgout_filename[0], "r", encoding="utf-8")
    except OSError as err:
        die(f"{err}")

    with cgout_file:
        cgout_line_num = 0

        def parse_die(msg: str) -> NoReturn:
            die(f"{cgout_file.name}:{cgout_line_num}: {msg}")

        def readline() -> str:
            nonlocal cgout_line_num
            cgout_line_num += 1
            return cgout_file.readline()

        # Read "desc:" lines.
        desc = ""
        while line := readline():
            if m := re.match(r"desc:\s+(.*)", line):
                desc += m.group(1) + "\n"
            else:
                break

        # Read "cmd:" line. (`line` is already set from the "desc:" loop.)
        if m := re.match(r"cmd:\s+(.*)", line):
            cmd = m.group(1)
        else:
            parse_die("missing a `command:` line")

        # Read "events:" line.
        line = readline()
        if m := re.match(r"events:\s+(.*)", line):
            events = Events(m.group(1))
        else:
            parse_die("missing an `events:` line")

        def mk_empty_dict_line_cc() -> DictLineCc:
            return defaultdict(events.mk_empty_cc)

        curr_fl = ""
        curr_flfn = Flfn(("", ""))

        # Different places where we accumulate CC data.
        dict_flfn_cc: DictFlfnCc = defaultdict(events.mk_empty_cc)
        dict_fl_dict_line_cc: DictFlDictLineCc = defaultdict(mk_empty_dict_line_cc)
        summary_cc = None

        # Compile the one hot regex.
        count_pat = re.compile(r"(\d+)\s+(.*)")

        # Line matching is done in order of pattern frequency, for speed.
        while True:
            line = readline()

            if m := count_pat.match(line):
                line_num = int(m.group(1))
                try:
                    cc = events.mk_cc(m.group(2))
                except ValueError:
                    parse_die("malformed or too many event counts")

                # Record this CC at the function level.
                flfn_cc = dict_flfn_cc[curr_flfn]
                flfn_cc += cc

                # Record this CC at the file/line level.
                line_cc = dict_fl_dict_line_cc[curr_fl][line_num]
                line_cc += cc

            elif line.startswith("fn="):
                curr_flfn = Flfn((curr_fl, line[3:-1]))

            elif line.startswith("fl="):
                curr_fl = line[3:-1]
                # A `fn=` line should follow, overwriting the function name.
                curr_flfn = Flfn((curr_fl, "<unspecified>"))

            elif m := re.match(r"summary:\s+(.*)", line):
                try:
                    summary_cc = events.mk_cc(m.group(1))
                except ValueError:
                    parse_die("too many event counts")

            elif line == "":
                break  # EOF

            elif line == "\n" or line.startswith("#"):
                # Skip empty lines and comment lines.
                pass

            else:
                parse_die(f"malformed line: {line[:-1]}")

    # Check if summary line was present.
    if not summary_cc:
        parse_die("missing `summary:` line, aborting")

    # Check summary is correct.
    total_cc = events.mk_empty_cc()
    for flfn_cc in dict_flfn_cc.values():
        total_cc += flfn_cc
    if summary_cc != total_cc:
        msg = (
            "`summary:` line doesn't match computed total\n"
            f"- summary: {summary_cc}\n"
            f"- total:   {total_cc}"
        )
        parse_die(msg)

    return (desc, cmd, events, dict_flfn_cc, dict_fl_dict_line_cc, summary_cc)


class CcPrinter:
    # Note: every `CcPrinter` gets the same `Events` object.
    events: Events

    # Note: every `CcPrinter` gets the same summary CC.
    summary_cc: Cc

    # The width of each event count column. (This column is also used for event
    # names.) For simplicity, its length matches `events.events`, even though
    # not all events are necessarily shown.
    count_widths: list[int]

    # The width of each percentage column. Zero if --show-percs is disabled.
    # Its length matches `count_widths`.
    perc_widths: list[int]

    def __init__(self, events: Events, ccs: list[Cc], summary_cc: Cc) -> None:
        self.events = events
        self.summary_cc = summary_cc

        # Find min and max value for each event. One of them will be the
        # widest value.
        min_cc = events.mk_empty_cc()
        max_cc = events.mk_empty_cc()
        for cc in ccs:
            for i, _ in enumerate(events.events):
                count = cc.counts[i]
                if count > max_cc.counts[i]:
                    max_cc.counts[i] = count
                elif count < min_cc.counts[i]:
                    min_cc.counts[i] = count

        # Find maximum width for each column.
        self.count_widths = [0] * events.num_events
        self.perc_widths = [0] * events.num_events
        for i, event in enumerate(events.events):
            # Get count and perc widths of the min and max CCs.
            (min_count, min_perc) = self.count_and_perc(min_cc, i)
            (max_count, max_perc) = self.count_and_perc(max_cc, i)

            # The event name goes in the count column.
            self.count_widths[i] = max(len(min_count), len(max_count), len(event))
            self.perc_widths[i] = max(len(min_perc), len(max_perc))

    def print_events(self, suffix: str) -> None:
        for i in self.events.show_indices:
            # The event name goes in the count column.
            event = self.events.events[i]
            nwidth = self.count_widths[i]
            pwidth = self.perc_widths[i]
            empty_perc = ""
            print(f"{event:<{nwidth}}{empty_perc:>{pwidth}} ", end="")

        print(suffix)

    def print_count_and_perc(self, i: int, count: str, perc: str) -> None:
        nwidth = self.count_widths[i]
        pwidth = self.perc_widths[i]
        print(f"{count:>{nwidth}}{perc:>{pwidth}} ", end="")

    def count_and_perc(self, cc: Cc, i: int) -> tuple[str, str]:
        count = f"{cc.counts[i]:,d}"  # commify
        if args.show_percs:
            if cc.counts[i] == 0:
                # Don't show percentages for "0" entries, it's just clutter.
                perc = ""
            else:
                summary_count = self.summary_cc.counts[i]
                if summary_count == 0:
                    perc = " (n/a)"
                else:
                    p = cc.counts[i] * 100 / summary_count
                    perc = f" ({p:.1f}%)"
        else:
            perc = ""

        return (count, perc)

    def print_cc(self, cc: Cc, suffix: str) -> None:
        for i in self.events.show_indices:
            (count, perc) = self.count_and_perc(cc, i)
            self.print_count_and_perc(i, count, perc)

        print("", suffix)

    def print_missing_cc(self, suffix: str) -> None:
        # Don't show percentages for "." entries, it's just clutter.
        for i in self.events.show_indices:
            self.print_count_and_perc(i, ".", "")

        print("", suffix)


# Used in various places in the output.
def print_fancy(text: str) -> None:
    fancy = "-" * 80
    print(fancy)
    print("--", text)
    print(fancy)


def print_cachegrind_profile(desc: str, cmd: str, events: Events) -> None:
    print_fancy("Cachegrind profile")
    print(desc, end="")
    print("Command:         ", cmd)
    print("Data file:       ", args.cgout_filename[0])
    print("Events recorded: ", *events.events)
    print("Events shown:    ", *events.show_events)
    print("Event sort order:", *events.sort_events)
    print("Threshold:       ", args.threshold)

    if len(args.include) == 0:
        print("Include dirs:     ")
    else:
        print(f"Include dirs:     {args.include[0]}")
        for include_dirname in args.include[1:]:
            print(f"                  {include_dirname}")

    print("Annotation:      ", "on" if args.annotate else "off")
    print()


def print_summary(events: Events, summary_cc: Cc) -> None:
    printer = CcPrinter(events, [summary_cc], summary_cc)
    print_fancy("Summary")
    printer.print_events("")
    print()
    printer.print_cc(summary_cc, "PROGRAM TOTALS")
    print()


def print_function_summary(
    events: Events, dict_flfn_cc: DictFlfnCc, summary_cc: Cc
) -> set[str]:
    # Only the first threshold percentage is actually used.
    threshold_index = events.sort_indices[0]

    # Convert the threshold from a percentage to an event count.
    threshold = args.threshold * abs(summary_cc.counts[threshold_index]) / 100

    def meets_threshold(flfn_and_cc: tuple[Flfn, Cc]) -> bool:
        cc = flfn_and_cc[1]
        return abs(cc.counts[threshold_index]) >= threshold

    # Create a list with the counts in sort order, so that left-to-right list
    # comparison does the right thing. Plus the `Flfn` at the end for
    # deterministic output when all the event counts are identical in two CCs.
    def key(flfn_and_cc: tuple[Flfn, Cc]) -> tuple[list[int], Flfn]:
        cc = flfn_and_cc[1]
        return ([abs(cc.counts[i]) for i in events.sort_indices], flfn_and_cc[0])

    # Filter out functions for which the primary sort event count is below the
    # threshold, and sort the remainder.
    filtered_flfns_and_ccs = filter(meets_threshold, dict_flfn_cc.items())
    sorted_flfns_and_ccs = sorted(filtered_flfns_and_ccs, key=key, reverse=True)
    sorted_ccs = list(map(lambda flfn_and_cc: flfn_and_cc[1], sorted_flfns_and_ccs))

    printer = CcPrinter(events, sorted_ccs, summary_cc)
    print_fancy("Function summary")
    printer.print_events(" file:function")
    print()

    # Print per-function counts.
    for flfn, flfn_cc in sorted_flfns_and_ccs:
        printer.print_cc(flfn_cc, f"{flfn[0]}:{flfn[1]}")

    print()

    # Files containing a function that met the threshold.
    return set(flfn_and_cc[0][0] for flfn_and_cc in sorted_flfns_and_ccs)


class AnnotatedCcs:
    line_nums_known_cc: Cc
    line_nums_unknown_cc: Cc
    unreadable_cc: Cc
    below_threshold_cc: Cc
    files_unknown_cc: Cc

    labels = [
        "  annotated: files known & above threshold & readable, line numbers known",
        "  annotated: files known & above threshold & readable, line numbers unknown",
        "unannotated: files known & above threshold & unreadable ",
        "unannotated: files known & below threshold",
        "unannotated: files unknown",
    ]

    def __init__(self, events: Events) -> None:
        self.line_nums_known_cc = events.mk_empty_cc()
        self.line_nums_unknown_cc = events.mk_empty_cc()
        self.unreadable_cc = events.mk_empty_cc()
        self.below_threshold_cc = events.mk_empty_cc()
        self.files_unknown_cc = events.mk_empty_cc()

    def ccs(self) -> list[Cc]:
        return [
            self.line_nums_known_cc,
            self.line_nums_unknown_cc,
            self.unreadable_cc,
            self.below_threshold_cc,
            self.files_unknown_cc,
        ]


def mk_warning(msg: str) -> str:
    return f"""\
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@ WARNING @@ WARNING @@ WARNING @@ WARNING @@ WARNING @@ WARNING @@ WARNING @@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
{msg}\
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
"""


def warn_src_file_is_newer(src_filename: str, cgout_filename: str) -> None:
    msg = f"""\
@ Source file '{src_filename}' is newer than data file '{cgout_filename}'.
@ Annotations may not be correct.
"""
    print(mk_warning(msg))


def warn_bogus_lines(src_filename: str) -> None:
    msg = f"""\
@@ Information recorded about lines past the end of '{src_filename}'.
"""
    print(mk_warning(msg), end="")


def print_annotated_src_file(
    events: Events,
    dict_line_cc: DictLineCc,
    src_file: TextIO,
    annotated_ccs: AnnotatedCcs,
    summary_cc: Cc,
) -> None:
    # If the source file is more recent than the cgout file, issue warning.
    if os.stat(src_file.name).st_mtime_ns > os.stat(args.cgout_filename[0]).st_mtime_ns:
        warn_src_file_is_newer(src_file.name, args.cgout_filename[0])

    printer = CcPrinter(events, list(dict_line_cc.values()), summary_cc)
    # The starting fancy has already been printed by the caller.
    printer.print_events("")
    print()

    # The CC for line 0 is special, holding counts attributed to the source
    # file but not to any particular line (due to incomplete debug info).
    # Annotate the start of the file with this info, if present.
    line0_cc = dict_line_cc.pop(0, None)
    if line0_cc:
        suffix = "<unknown (line 0)>"
        printer.print_cc(line0_cc, suffix)
        annotated_ccs.line_nums_unknown_cc += line0_cc
        print()

    # Find interesting line ranges: all lines with a CC, and all lines within
    # `args.context` lines of a line with a CC.
    line_nums = list(sorted(dict_line_cc.keys()))
    pairs: list[tuple[int, int]] = []
    n = len(line_nums)
    i = 0
    context = args.context
    while i < n:
        lo = max(line_nums[i] - context, 1)  # `max` to prevent negatives
        while i < n - 1 and line_nums[i] + 2 * context >= line_nums[i + 1]:
            i += 1
        hi = line_nums[i] + context
        pairs.append((lo, hi))
        i += 1

    def print_lines(pairs: list[tuple[int, int]]) -> None:
        line_num = 0
        while pairs:
            src_line = ""
            (lo, hi) = pairs.pop(0)
            while line_num < lo - 1:
                src_line = src_file.readline()
                line_num += 1
                if not src_line:
                    return  # EOF

            # Print line number, unless start of file.
            if lo != 1:
                print("-- line", lo, "-" * 40)

            while line_num < hi:
                src_line = src_file.readline()
                line_num += 1
                if not src_line:
                    return  # EOF
                if line_nums and line_num == line_nums[0]:
                    printer.print_cc(dict_line_cc[line_num], src_line[:-1])
                    annotated_ccs.line_nums_known_cc += dict_line_cc[line_num]
                    del line_nums[0]
                else:
                    printer.print_missing_cc(src_line[:-1])

            # Print line number.
            print("-- line", hi, "-" * 40)

    # Annotate chosen lines, tracking total annotated counts.
    if pairs:
        print_lines(pairs)

        # If there was info on lines past the end of the file, warn.
        if line_nums:
            print()
            for line_num in line_nums:
                printer.print_cc(dict_line_cc[line_num], f"<bogus line {line_num}>")
                annotated_ccs.line_nums_known_cc += dict_line_cc[line_num]

            print()
            warn_bogus_lines(src_file.name)

        print()


# This (partially) consumes `dict_fl_dict_line_cc`.
def print_annotated_src_files(
    events: Events,
    ann_src_filenames: set[str],
    dict_fl_dict_line_cc: DictFlDictLineCc,
    summary_cc: Cc,
) -> AnnotatedCcs:
    annotated_ccs = AnnotatedCcs(events)

    def add_dict_line_cc_to_cc(dict_line_cc: DictLineCc | None, accum_cc: Cc) -> None:
        if dict_line_cc:
            for line_cc in dict_line_cc.values():
                accum_cc += line_cc

    # Exclude the unknown ("???") file, which is unannotatable.
    ann_src_filenames.discard("???")
    dict_line_cc = dict_fl_dict_line_cc.pop("???", None)
    add_dict_line_cc_to_cc(dict_line_cc, annotated_ccs.files_unknown_cc)

    # Prepend "" to the include dirnames so things work in the case where the
    # filename has the full path.
    include_dirnames = args.include.copy()
    include_dirnames.insert(0, "")

    def print_ann_fancy(src_filename: str) -> None:
        print_fancy(f"Annotated source file: {src_filename}")

    for src_filename in sorted(ann_src_filenames):
        readable = False
        for include_dirname in include_dirnames:
            if include_dirname == "":
                full_src_filename = src_filename
            else:
                full_src_filename = os.path.join(include_dirname, src_filename)

            try:
                with open(full_src_filename, "r", encoding="utf-8") as src_file:
                    dict_line_cc = dict_fl_dict_line_cc.pop(src_filename, None)
                    assert dict_line_cc is not None
                    print_ann_fancy(src_file.name)  # includes full path
                    print_annotated_src_file(
                        events,
                        dict_line_cc,
                        src_file,
                        annotated_ccs,
                        summary_cc,
                    )

                readable = True
                break
            except OSError:
                pass

        if not readable:
            dict_line_cc = dict_fl_dict_line_cc.pop(src_filename, None)
            add_dict_line_cc_to_cc(dict_line_cc, annotated_ccs.unreadable_cc)

            print_ann_fancy(src_filename)
            print("This file was unreadable")
            print()

    # Sum the CCs remaining in `dict_fl_dict_line_cc`, which are all in files
    # below the threshold.
    for dict_line_cc in dict_fl_dict_line_cc.values():
        add_dict_line_cc_to_cc(dict_line_cc, annotated_ccs.below_threshold_cc)

    return annotated_ccs


def print_annotation_summary(
    events: Events,
    annotated_ccs: AnnotatedCcs,
    summary_cc: Cc,
) -> None:
    # Show how many events were covered by annotated lines above.
    printer = CcPrinter(events, annotated_ccs.ccs(), summary_cc)
    print_fancy("Annotation summary")
    printer.print_events("")
    print()

    total_cc = events.mk_empty_cc()
    for (cc, label) in zip(annotated_ccs.ccs(), AnnotatedCcs.labels):
        printer.print_cc(cc, label)
        total_cc += cc

    print()

    # Internal sanity check.
    if summary_cc != total_cc:
        msg = (
            "`summary:` line doesn't match computed annotated counts\n"
            f"- summary:   {summary_cc}\n"
            f"- annotated: {total_cc}"
        )
        die(msg)


def main() -> None:
    (
        desc,
        cmd,
        events,
        dict_flfn_cc,
        dict_fl_dict_line_cc,
        summary_cc,
    ) = read_cgout_file()

    # Each of the following calls prints a section of the output.

    print_cachegrind_profile(desc, cmd, events)

    print_summary(events, summary_cc)

    ann_src_filenames = print_function_summary(events, dict_flfn_cc, summary_cc)

    if args.annotate:
        annotated_ccs = print_annotated_src_files(
            events, ann_src_filenames, dict_fl_dict_line_cc, summary_cc
        )

        print_annotation_summary(events, annotated_ccs, summary_cc)


if __name__ == "__main__":
    main()
