#!/usr/bin/env python3

import argparse
import math
import os
import subprocess
import sys
import threading
import time

try:
    import psutil
except ImportError:
    print(f'{sys.argv[0]}: the psutil module is required.', file=sys.stderr)
    sys.exit(1)

try:
    import matplotlib
    import matplotlib.pyplot as plt
    from matplotlib.lines import Line2D
except ImportError:
    plt = None


def to_gigabyte(value):
    return value / 1024**3


def to_megabyte(value):
    return value / 1024**2


INTERVAL = 0.33
LW = 0.5

global_n = 0
global_cpu_data_sum = 0
global_memory_data_sum = 0
global_cpu_data_max = 0
global_load_data_max = 0
global_memory_data_min = to_gigabyte(psutil.virtual_memory().total)
global_memory_data_max = 0
global_swap_data_min = to_gigabyte(psutil.swap_memory().total)
global_swap_data_max = 0
global_disk_data_total = to_gigabyte(psutil.disk_usage('.').total)
global_disk_data_start = to_gigabyte(psutil.disk_usage('.').used)
global_disk_last_read = None
global_disk_last_write = None

global_timestamps = []
global_cpu_data = []
global_load_data = []
global_memory_data = []
global_process_usage = []
global_process_hogs = {}
global_disk_read_data = []
global_disk_write_data = []

process_name_map = {}
lock = threading.Lock()

done = False
start_ts = time.monotonic()
cpu_count = psutil.cpu_count()

special_processes = {'linker': 'gold',
                     'WPA': 'deepskyblue',
                     'WPA-stream': 'lightblue',
                     'ltrans': 'forestgreen',
                     'as': 'coral',
                     'GCC': 'gray',
                     'clang': 'darkgray',
                     'rust': 'brown',
                     'go': 'hotpink',
                     'dwz': 'limegreen',
                     'rpm/rpm2cpio/dpkg': 'plum',
                     'xsltproc': 'bisque'}
for i, k in enumerate(special_processes.keys()):
    process_name_map[k] = i


descr = 'Run command and measure memory and CPU utilization'
parser = argparse.ArgumentParser(description=descr)
parser.add_argument('command', metavar='command',
                    help='Command', nargs=argparse.REMAINDER)
parser.add_argument('-c', '--command', dest='command1',
                    help='command as a single argument')
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose')
parser.add_argument('-S', '--summary-only', dest='summary_only',
                    action='store_true',
                    help='No plot, just a summary at the end')
parser.add_argument('-b', '--base-memory', action='store_true',
                    help='Adjust memory to include the system load')
parser.add_argument('-s', '--separate-ltrans', action='store_true',
                    help='Separate LTRANS processes in graph')
parser.add_argument('-o', '--output', default='usage.svg',
                    help='Path to output image (default: usage.svg)')
parser.add_argument('-r', '--ranges',
                    help='Plot only the selected time ranges '
                    '(e.g. 20-30, 0-1000)')
parser.add_argument('-t', '--title', help='Graph title')
parser.add_argument('-m', '--memory-hog-threshold', type=float,
                    help='Report about processes that consume the amount of '
                    'memory (in GiB)')
parser.add_argument('-f', '--frequency', type=float,
                    default=INTERVAL,
                    help='Frequency of measuring (in seconds)')
parser.add_argument('-j', '--jobs', type=int,
                    default=cpu_count, dest='used_cpus',
                    help='Scale up CPU data to used CPUs '
                    'instead of available CPUs')
parser.add_argument('--y-scale', type=int,
                    help='Minimal y-scale (in GiB)')

args = parser.parse_args()

if args.command1 and args.command:
    print(f'{sys.argv[0]}: either use -c "<shell command>", '
          'or append the command', file=sys.stderr)
    sys.exit(1)

if not args.summary_only and plt is None:
    print(f'{sys.argv[0]}: use --summary-only, '
          'or install the matplotlib module', file=sys.stderr)
    sys.exit(1)

cpu_scale = cpu_count / args.used_cpus


def get_process_name(proc):
    name = proc.name()
    cmdline = proc.cmdline()
    if name in ('ld', 'ld.gold', 'ld.lld', 'ld.mold'):
        return 'linker'
    elif name == 'lto1-wpa':
        return 'WPA'
    elif name == 'lto1-wpa-stream':
        return 'WPA-stream-out'
    elif name in ('cc1', 'cc1plus', 'cc1objc', 'f951', 'd21', 'go1', 'gnat1'):
        return 'GCC'
    elif name.startswith('clang'):
        return 'clang'
    elif name.startswith('rust'):
        return 'rust'
    elif name in ('as', 'dwz', 'go'):
        return name
    elif name in ('rpmbuild', 'rpm2cpio') or name.startswith('dpkg'):
        return 'rpm/rpm2cpio/dpkg'
    elif '-fltrans' in cmdline:
        if args.separate_ltrans:
            return 'ltrans-%d' % proc.pid
        else:
            return 'ltrans'
    return None


def record_process_memory_hog(proc, memory, timestamp):
    if args.memory_hog_threshold:
        if memory >= args.memory_hog_threshold:
            cmd = ' '.join(proc.cmdline())
            tpl = (memory, timestamp)
            if cmd not in global_process_hogs:
                global_process_hogs[cmd] = tpl
            elif memory > global_process_hogs[cmd][0]:
                global_process_hogs[cmd] = tpl


def record():
    global global_n, global_cpu_data_sum, global_cpu_data_max, global_load_data_max
    global global_memory_data_sum, global_memory_data_min
    global global_memory_data_max
    global global_swap_data_min, global_swap_data_max
    global global_disk_last_read, global_disk_last_write, global_disk_read_data, global_disk_write_data

    active_pids = {}
    while not done:
        timestamp = time.monotonic() - start_ts
        used_cpu = psutil.cpu_percent(interval=args.frequency) * cpu_scale
        used_load = 100 * psutil.getloadavg()[0] / cpu_count
        used_memory = to_gigabyte(psutil.virtual_memory().used)
        used_swap = to_gigabyte(psutil.swap_memory().used)
        global_timestamps.append(timestamp)
        if not args.summary_only:
            global_memory_data.append(used_memory)
            global_cpu_data.append(used_cpu)
            global_load_data.append(used_load)
            disk_info = psutil.disk_io_counters()
            if global_disk_last_read is None:
                global_disk_last_read = disk_info.read_bytes
                global_disk_last_write = disk_info.write_bytes

            duration = 1 / INTERVAL
            global_disk_read_data.append(to_megabyte(duration * (disk_info.read_bytes - global_disk_last_read)))
            global_disk_last_read = disk_info.read_bytes
            global_disk_write_data.append(to_megabyte(duration * (disk_info.write_bytes - global_disk_last_write)))
            global_disk_last_write = disk_info.write_bytes

        global_n += 1
        global_cpu_data_sum += used_cpu
        global_memory_data_sum += used_memory
        global_cpu_data_max = max(global_cpu_data_max, used_cpu)
        global_load_data_max = max(global_load_data_max, used_load)
        global_memory_data_min = min(global_memory_data_min, used_memory)
        global_memory_data_max = max(global_memory_data_max, used_memory)
        global_swap_data_min = min(global_swap_data_min, used_swap)
        global_swap_data_max = max(global_swap_data_max, used_swap)

        entry = {}
        seen_pids = set()
        for proc in psutil.Process().children(recursive=True):
            try:
                memory = to_gigabyte(proc.memory_info().rss)
                record_process_memory_hog(proc, memory, timestamp)
                name = get_process_name(proc)
                if name:
                    seen_pids.add(proc.pid)
                    if proc.pid not in active_pids:
                        active_pids[proc.pid] = proc
                    else:
                        proc = active_pids[proc.pid]
                    cpu = proc.cpu_percent() / args.used_cpus
                    if name not in process_name_map:
                        length = len(process_name_map)
                        process_name_map[name] = length
                    if name not in entry:
                        entry[name] = {'memory': 0, 'cpu': 0}
                    entry[name]['cpu'] += cpu
                    # FIXME: ignore WPA streaming memory - COW makes it bogus
                    if name != 'WPA-stream-out':
                        entry[name]['memory'] += memory
            except Exception:
                # the process can be gone
                pass
        for pid in list(active_pids.keys()):
            if pid not in seen_pids:
                del active_pids[pid]
        if args.verbose:
            print(entry, flush=True)
        if not args.summary_only:
            global_process_usage.append(entry)


def stack_values(process_usage, key):
    stacks = []
    for _ in range(len(process_name_map)):
        stacks.append([])
    for values in process_usage:
        for k, v in process_name_map.items():
            if k in values:
                stacks[v].append(values[k][key])
            else:
                stacks[v].append(0)
    return stacks


def get_footnote():
    hostname = os.uname()[1].split('.')[0]
    cpu_average = global_cpu_data_sum / global_n
    cpu_max = global_cpu_data_max
    base_memory = global_memory_data_min
    peak_memory = global_memory_data_max
    total_mem = to_gigabyte(psutil.virtual_memory().total)
    return (f'host: {hostname}; CPUs: {args.used_cpus}/{cpu_count};'
            f' CPU avg: {cpu_average:.0f}%;'
            f' CPU max: {cpu_max:.0f}%;'
            f' base memory: {base_memory:.1f} GiB;'
            f' peak memory: {peak_memory:.1f} GiB;'
            f' total memory: {total_mem:.1f} GiB')


def get_footnote2():
    peak_swap = global_swap_data_max
    total_swap = to_gigabyte(psutil.swap_memory().total)
    disk_total = global_disk_data_total
    disk_start = global_disk_data_start
    disk_end = to_gigabyte(psutil.disk_usage('.').used)
    disk_delta = disk_end - disk_start
    load_max = global_load_data_max
    return (f'taken: {int(global_timestamps[-1])} s;'
            f' load max (1m): {load_max:.0f}%; swap peak/total: {peak_swap:.1f}/{total_swap:.1f} GiB;'
            f' disk start/end/total: {disk_start:.1f}/{disk_end:.1f}/{disk_total:.1f} GiB;'
            f' disk delta: {disk_delta:.1f} GiB')


def generate_graph(time_range):
    timestamps = []
    cpu_data = []
    load_data = []
    memory_data = []
    process_usage = []
    disk_read_usage = []
    disk_write_usage = []

    # filter date by timestamp
    for i, ts in enumerate(global_timestamps):
        if not time_range or time_range[0] <= ts and ts <= time_range[1]:
            timestamps.append(ts)
            cpu_data.append(global_cpu_data[i])
            load_data.append(global_load_data[i])
            memory_data.append(global_memory_data[i])
            process_usage.append(global_process_usage[i])
            disk_read_usage.append(global_disk_read_data[i])
            disk_write_usage.append(global_disk_write_data[i])

    if not timestamps:
        if args.verbose:
            print('No data for range: %s' % str(time_range))
        return

    peak_memory = max(memory_data)

    fig, (cpu_subplot, mem_subplot, disk_subplot) = plt.subplots(3, sharex=True)
    title = args.title if args.title else ''
    if time_range:
        title += ' (%d-%d s)' % (time_range[0], time_range[1])
    fig.suptitle(title, fontsize=17)
    fig.set_figheight(8)
    fig.set_figwidth(10)
    # scale cpu axis
    local_peak_cpu = max(cpu_data + load_data)
    cpu_ylimit = (local_peak_cpu // 10) * 11 + 5
    if cpu_ylimit > 300:
        cpu_ylimit = 300
    cpu_subplot.set_title('CPU usage')
    cpu_subplot.set_ylabel('%')
    cpu_subplot.plot(timestamps, cpu_data, c='blue', lw=LW, label='total')
    cpu_subplot.plot(timestamps, load_data, c='cyan', lw=LW, label='load')
    cpu_subplot.set_ylim([0, cpu_ylimit])
    cpu_subplot.axhline(color='r', alpha=0.5, y=100.0 / args.used_cpus, lw=LW,
                        linestyle='dotted', label='single core')
    cpu_subplot.set_xlim(left=time_range[0] if time_range else 0)
    cpu_subplot.grid(True)

    mem_subplot.plot(timestamps, memory_data, c='blue', lw=LW, label='total')
    mem_subplot.set_title('Memory usage')
    mem_subplot.set_ylabel('GiB')

    disk_subplot.plot(timestamps, disk_read_usage, c='green', lw=LW, label='read')
    disk_subplot.plot(timestamps, disk_write_usage, c='red', lw=LW, label='write')
    disk_subplot.set_title('Disk read/write')
    disk_subplot.set_ylabel('MiB/s')
    disk_subplot.set_xlabel('time')

    # scale it to a reasonable limit
    limit = 1
    while peak_memory > limit:
        limit *= 2
    if limit > 2 and limit * 0.75 >= peak_memory:
        limit = int(limit * 0.75)

    if args.y_scale and limit < args.y_scale:
        limit = args.y_scale

    mem_subplot.set_ylim([0, 1.1 * limit])
    mem_subplot.set_yticks(range(0, limit + 1, math.ceil(limit / 8)))
    mem_subplot.grid(True)

    disk_subplot.grid(True)

    colors = list(matplotlib.colormaps['tab20c'].colors * 100)
    for name, color in special_processes.items():
        if name in process_name_map:
            colors[process_name_map[name]] = color

    mem_stacks = stack_values(process_usage, 'memory')
    cpu_stacks = stack_values(process_usage, 'cpu')
    if mem_stacks:
        mem_subplot.stackplot(timestamps, mem_stacks,
                              colors=colors)
        cpu_subplot.stackplot(timestamps, cpu_stacks,
                              colors=colors)

        # generate custom legend
        colors = special_processes.values()
        custom_lines = [Line2D([0], [0], color=x, lw=5) for x in colors]
        custom_lines.insert(0, Line2D([0], [0], color='red', lw=LW))
        custom_lines.insert(0, Line2D([0], [0], color='green', lw=LW))
        custom_lines.insert(0, Line2D([0], [0], color='cyan', lw=LW))
        custom_lines.insert(0, Line2D([0], [0], color='b', lw=LW))
        custom_lines.insert(0, Line2D([0], [0], color='r', alpha=0.5,
                                      linestyle='dotted', lw=LW))
        names = ['single core', 'total', 'load', 'disk read', 'disk write'] + list(special_processes.keys())
        fig.legend(custom_lines, names, loc='right', prop={'size': 6})

    filename = args.output
    if time_range:
        tr = '-%d-%d' % (time_range[0], time_range[1])
        filename = os.path.splitext(args.output)[0] + tr + '.svg'
    plt.subplots_adjust(bottom=0.12)
    plt.figtext(0.1, 0.04, get_footnote(), fontsize='small')
    plt.figtext(0.1, 0.01, get_footnote2(), fontsize='small')
    plt.savefig(filename)
    if args.verbose:
        print('Saving plot to %s' % filename)


def summary():
    print(f'SUMMARY: {get_footnote()}')
    print(f'SUMMARY: {get_footnote2()}')
    if global_process_hogs:
        print(f'PROCESS MEMORY HOGS (>={args.memory_hog_threshold:.1f} GiB):')
        items = sorted(global_process_hogs.items(), key=lambda x: x[1][0],
                       reverse=True)
        for cmdline, (memory, ts) in items:
            print(f'  {memory:.1f} GiB: {ts:.1f} s: {cmdline}')


thread = threading.Thread(target=record, args=())
thread.start()

ranges = []
if args.ranges:
    for r in args.ranges.split(','):
        parts = r.split('-')
        assert len(parts) == 2
        ranges.append([int(x) for x in parts])

if args.verbose:
    print('Ranges are %s' % str(ranges))
    print('Running command', flush=True)

cp = None
try:
    if args.command1:
        cp = subprocess.run(args.command1, shell=True)
    else:
        cp = subprocess.run(args.command)
except KeyboardInterrupt:
    rv = 2
finally:
    done = True
    thread.join()
    summary()
    if global_memory_data:
        min_memory = min(global_memory_data)
        if not args.base_memory:
            global_memory_data = [x - min_memory for x in global_memory_data]

        if plt:
            generate_graph(None)
            for r in ranges:
                generate_graph(r)
    if cp:
        rv = cp.returncode

if rv != 0:
    print(f'\nWARNING: non-zero return code returned: {rv}')
sys.exit(rv)
