#!/usr/bin/env python3

# SPDX-License-Identifier: GPL-3.0-or-later

"""
fsmon: A Real-Time Btrfs I/O Monitor
------------------------------------

`fsmon` monitors the I/O activity of Btrfs filesystems in real time,
displaying bandwidth and IOPS statistics for detected filesystems
and their devices.

It combines I/O statistics from all member devices of each Btrfs
filesystem, providing a unified view of the filesystem's overall
activity.

Features:
---------
- Real-time monitoring of read/write bandwidth and IOPS.
- Visual charts for I/O statistics.
- Dynamic terminal size handling.
- Lightweight and efficient, leveraging sysfs for minimal overhead.

Requirements:
-------------
- Python 3.6 or higher.
- Btrfs filesystems mounted on the system.
- Sufficient permissions to access `/sys/fs/btrfs` and related
  devices in `/sys/block`.

Usage:
------
 `-h` or `--help`:    Display usage information.
 `-v` or `--version`: Display version.

License:
--------
This program is licensed under the GNU General Public License v3.0
or later.
"""

__description__ = "A real-time Btrfs I/O monitor for tracking filesystem activity."
__author__      = "Forza <forza@tnonline.net>"
__license__     = "GPL-3.0-or-later"
__version__     = "0.1.0"

import argparse
import curses
import os
import sys
import time

from collections import defaultdict


####### Configuration Options ########

# Colors
USE_TERM_COLORS = True      # Use terminal's default colour
COLOR_CHART_BW_READ    = 3  # Green
COLOR_CHART_BW_WRITE   = 2  # Red
COLOR_CHART_IOPS_READ  = 7  # Cyan
COLOR_CHART_IOPS_WRITE = 6  # Magenta
COLOR_SELECTED         = 7  # Cyan
COLOR_COL_HEADER       = 4  # Yellow
COLOR_HEADER           = 8  # Terminal's default colour
COLOR_FOOTER           = 8  # Terminal's default colour
COLOR_HLINE            = 4  # Yellow

# Column widths (label, read, write, iops)
COL_LABEL    = 12
COL_READ_BW  = 11
COL_WRITE_BW = 11
COL_IOPS     = 11

# Minimum allowed terminal size
MIN_WIDTH = COL_LABEL + COL_READ_BW + COL_WRITE_BW + COL_IOPS

# Chart size
CHART_HEIGHT = 11
CHART_WIDTH  = 61

# Btrfs labels and member devices are read from sysfs
SYSFS_PATH = "/sys/fs/btrfs"

####### Configuration End ########

def init_colors():
    """
    Initialise colors
    """
    curses.start_color()

    if USE_TERM_COLORS:
        curses.use_default_colors()
        default_bg = -1  # Terminal's default background
    else:
        default_bg = curses.COLOR_BLACK

    # Initialize all 8 default colour pairs
    curses.init_pair(1, curses.COLOR_BLACK, default_bg)   # Black
    curses.init_pair(2, curses.COLOR_RED, default_bg)     # Red
    curses.init_pair(3, curses.COLOR_GREEN, default_bg)   # Green
    curses.init_pair(4, curses.COLOR_YELLOW, default_bg)  # Yellow
    curses.init_pair(5, curses.COLOR_BLUE, default_bg)    # Blue
    curses.init_pair(6, curses.COLOR_MAGENTA, default_bg) # Magenta
    curses.init_pair(7, curses.COLOR_CYAN, default_bg)    # Cyan
    curses.init_pair(8, curses.COLOR_WHITE, default_bg)   # White


def parse_arguments():
    """
    Parse command-line arguments for fsmon.
    """
    parser = argparse.ArgumentParser(
        description=__description__
    )
    parser.add_argument(
        "-v", "--version", action="version", version=f"fsmon {__version__}",
        help="Show program version and exit."
    )
    return parser.parse_args()


def get_btrfs_filesystems():
    """
    Fetch all Btrfs filesystems and their devices.
    """
    btrfs_fs = {}  # Dictionary: UUID -> list of device paths
    labels   = {}  # Dictionary: UUID -> label (or UUID if no label)

    # UUIDs are directory entries in SYSFS_PATH
    try:
        uuids = os.listdir(SYSFS_PATH)
    except FileNotFoundError:
        print(f"Error: '{SYSFS_PATH}' does not exist. Ensure sysfs is mounted.", file=sys.stderr)
        exit(1)
    except PermissionError:
        print(f"Error: Permission denied when accessing '{SYSFS_PATH}'", file=sys.stderr)
        exit(1)
    except OSError as e:
        print(f"Error: Unable to access '{SYSFS_PATH}': {e}", file=sys.stderr)
        exit(1)

    # Process each UUID
    for uuid in uuids:
        devices_path = os.path.join(SYSFS_PATH, uuid, "devices")
        label_path   = os.path.join(SYSFS_PATH, uuid, "label")

        # Get filesystem label and member devices
        if os.path.isdir(devices_path) and os.path.exists(label_path):
            # Read the devices directory
            try:
                devices = [
                    os.path.join(devices_path, d, "stat")
                    for d in os.listdir(devices_path)
                ]
                btrfs_fs[uuid] = devices
            except PermissionError:
                print(f"Error: Permission denied when accessing '{devices_path}'", file=sys.stderr)
                continue
            except OSError as e:
                print(f"Error: Unable to read devices from '{devices_path}': {e}", file=sys.stderr)
                continue

            # Get filesystem label
            label = ""
            try:
                with open(label_path, "r") as file:
                    label = file.read().strip()
                    # Decode label as UTF-8 with error handling
                    label = label.encode("utf-8").decode("utf-8", errors="replace")
            except PermissionError:
                print(f"Error: Permission denied when reading '{label_path}'", file=sys.stderr)
                continue
            except OSError as e:
                print(f"Error: Unable to read '{label_path}': {e}", file=sys.stderr)
                continue

            # Use UUID if no label is available
            labels[uuid] = label if label else uuid

    # Check if any filesystems were found
    if not btrfs_fs:
        print(f"Error: No Btrfs filesystems found in '{SYSFS_PATH}'")
        exit(1)

    # Return the list of found filesystems
    return btrfs_fs, labels

def get_device_stats(btrfs_fs):
    """
    Calculate aggregated statistics for each Btrfs filesystem.
    """
    fs_stats    = {}  # Dictionary: UUID -> aggregated statistics
    sector_size = 512
    # Linux device/stat file always use 512-byte sector size.
    # Reference: https://www.kernel.org/doc/Documentation/block/stat.txt

    # Iterate through each UUID and its associated devices
    for uuid, devices in btrfs_fs.items():
        # Initialise counters for aggregated statistics
        read_bytes  = 0
        write_bytes = 0
        read_ops    = 0
        write_ops   = 0

        # Iterate through each device stat file
        for dev_stat_path in devices:
            try:
                # Read the stat file and parse its fields
                with open(dev_stat_path, "r") as file:
                    stats = file.read().split()
                # Ensure the stat file contains the expected fields
                if len(stats) < 7:
                    print(f"Warning: Stat file '{dev_stat_path}' is malformed or incomplete.", file=sys.stderr)
                    continue
                # Update operation counts and byte counters
                read_ops     += int(stats[0])  # Reads completed
                write_ops    += int(stats[4])  # Writes completed
                read_sectors  = int(stats[2])  # Sectors read
                write_sectors = int(stats[6])  # Sectors written
                read_bytes   += read_sectors * sector_size
                write_bytes  += write_sectors * sector_size
            except FileNotFoundError:
                # Skip devices where the stat file is missing
                print(f"Warning: Stat file not found for device at '{dev_stat_path}'", file=sys.stderr)
                continue
            except PermissionError:
                # Skip devices we cannot read
                print(f"Error: Permission denied when reading '{dev_stat_path}'", file=sys.stderr)
                continue
            except ValueError:
                # Skip devices where the stat file contains invalid data
                print(f"Warning: Invalid data in stat file '{dev_stat_path}'", file=sys.stderr)
                continue
            except OSError as e:
                # Abort on other errors
                print(f"Error: Unable to read '{dev_stat_path}': {e}", file=sys.stderr)
                exit(1)

        # Store aggregated statistics for the current UUID
        fs_stats[uuid] = {
            "read_bytes": read_bytes,
            "write_bytes": write_bytes,
            "read_ops": read_ops,
            "write_ops": write_ops
        }
    return fs_stats


def format_iec(value):
    """
    Convert numbers to IEC units: B, KiB, MiB, GiB...
    """
    units = ["B", "KiB", "MiB", "GiB", "TiB"]
    for unit in units:
        if value < 1024:
            return f"{value:.1f} {unit}"
        value /= 1024

    return f"{value:.1f} PiB"


def format_base10(value):
    """
    Convert numbers to base-10 units: k, M, G...
    """
    prefixes = {
        'P': 1e15,  # peta
        'T': 1e12,  # tera
        'G': 1e9,   # giga
        'M': 1e6,   # mega
        'k': 1e3    # kilo
    }

    v = float(value)
    for suffix, threshold in prefixes.items():
        if v >= threshold:
            return f"{v / threshold:.1f}{suffix}"

    return f"{v:.1f}"


def display_chart(stdscr, title, data_list, row_start, col_start, CHART_WIDTH, color, use_iops=False):
    """
    Renders a single chart.
    """
    # Store the max value
    max_val = max(max(data_list), 1)

    # Print chart title
    stdscr.addstr(row_start, col_start + 9 + (CHART_WIDTH // 2) - (len(title) //2), title)

    # Number of diagram rows
    y_axis_rows = CHART_HEIGHT - 2
    for i in range(y_axis_rows-1):
        # Set row max value
        threshold = max_val * ((y_axis_rows-1) - i) / (y_axis_rows-1)
        # Construct a complete row
        chart_row = "".join(
            "|" if val > threshold else " " if i == (y_axis_rows-2) else "."
            for val in data_list[-CHART_WIDTH:]
        ).ljust(CHART_WIDTH-1)

        if use_iops:
            y_axis_label = format_base10(threshold)
        else:
            y_axis_label = format_iec(threshold)

        # Draw Y-axis label
        stdscr.addstr(row_start + 1 + i, col_start, f"{y_axis_label:>10} ")

        # Draw chart rows
        if i == (y_axis_rows-2):
            # Underline the bottom row
            stdscr.addstr(row_start + 1 + i, col_start + 11, chart_row, curses.A_UNDERLINE | curses.color_pair(color) )
        else:
            stdscr.addstr(row_start + 1 + i, col_start + 11, chart_row, curses.color_pair(color))
    # Draw X-axis scale
    x_axis = " ".join(f"{x:>4}" for x in range(CHART_WIDTH-1, -1, -5))
    stdscr.addstr(row_start + y_axis_rows, col_start + 8, x_axis)


def display_ui(stdscr, btrfs_fs, fs_labels):
    """
    Main UI.
    """
    curses.curs_set(0)
    stdscr.nodelay(1)
    init_colors()

    selected_idx = 0
    use_labels = True  # Show labels as default
    show_iops_charts = False  # Toggles display of iops charts

    prev_stats = {}
    first_skipped = set()

    history = defaultdict(
        lambda: {
            "read_bw": [0] * CHART_WIDTH,
            "write_bw": [0] * CHART_WIDTH,
            "read_iops": [0] * CHART_WIDTH,
            "write_iops": [0] * CHART_WIDTH,
        }
    )

###
# Begin main UI loop
###
    try:
        # Main UI loop
        while True:
            # Check for keyboard input
            key = stdscr.getch()
            if key == curses.KEY_UP:
                selected_idx = max(0, selected_idx - 1)
            elif key == curses.KEY_DOWN:
                selected_idx += 1
                deltas = {}
            elif key in [ord("q"), ord("Q")]:
                break
            elif key in [ord("l"), ord("L")]:
                use_labels = not use_labels
            elif key in [ord("i"), ord("I")]:
                show_iops_charts = not show_iops_charts

            # Get the current statistics for all Btrfs filesystems
            current_stats = get_device_stats(btrfs_fs)

            # Calculate deltas
            deltas = {}
            for uuid, stats in current_stats.items():
                # Get the previous stats for this UUID; default to 0 for all fields if not found
                prev = prev_stats.get(
                    uuid,
                    {"read_bytes": 0, "write_bytes": 0, "read_ops": 0, "write_ops": 0},
                )
                # Skip the first iteration for this UUID to avoid incorrect deltas
                # This ensures we have a baseline before calculating differences
                if uuid not in first_skipped:
                    first_skipped.add(uuid)
                    prev_stats[uuid] = stats
                    continue

                # Calculate the delta between current and previous stats
                deltas[uuid] = {
                    "read_bw": stats["read_bytes"] - prev["read_bytes"],
                    "write_bw": stats["write_bytes"] - prev["write_bytes"],
                    "read_iops": stats["read_ops"] - prev["read_ops"],
                    "write_iops": stats["write_ops"] - prev["write_ops"],
                }
            # Update the previous stats to the current stats for the next iteration
            prev_stats = current_stats

            # List of UUIDs
            uuids = list(btrfs_fs.keys())

            # Clear screen
            stdscr.erase()

            # Prevent drawing UI if terminal is too small
            height, width = stdscr.getmaxyx()
            MIN_HEIGHT = len(uuids) + 5  # Enough to show list without charts
            if (height < MIN_HEIGHT) or (width < MIN_WIDTH):
                stdscr.addstr(
                    0, 0, f"Terminal too small (need ≥ {MIN_HEIGHT}x{MIN_WIDTH})."
                )
                time.sleep(1)
                continue

            # Print header and footer
            stdscr.attron(curses.color_pair(COLOR_HEADER))
            stdscr.addstr(0, 0, "Btrfs Filesystem I/O Monitor")
            stdscr.addstr(0, width - 6 - len(__version__), f"fsmon {__version__}")
            stdscr.attroff(curses.color_pair(COLOR_HEADER))
            stdscr.attron(curses.color_pair(COLOR_FOOTER))
            stdscr.addstr(height - 1, 0, "Keys: q=quit, L=labels, i=iops")
            stdscr.addstr(height - 1, width - len(f"{width}x{height}") - 1, f"{width}x{height}")
            stdscr.attroff(curses.color_pair(COLOR_FOOTER))

            # Determine the maximum label length
            if use_labels:
                 max_label_len = max(
                        len(fs_labels[u])
                        for u in uuids
                )
            else:
                max_label_len = 36

            # Length of fixed columns combined
            fixed_cols = COL_READ_BW + COL_WRITE_BW + COL_IOPS

            # Calculate label column length and set
            # to COL_LABEL length if label is too short
            label_col = min(
                max_label_len,
                max(
                    COL_LABEL,
                    width - fixed_cols - 2
                )
            )
            # Define header row text
            col_header = (
                f"{'Filesystem':<{label_col}}"
                f"{'Read/s':>{COL_READ_BW}}"
                f"{'Write/s':>{COL_WRITE_BW}}"
                f"{'IOPS(R/W)':>{COL_IOPS}}"
            )
            # Write out header text
            stdscr.attron(curses.color_pair(COLOR_COL_HEADER))
            stdscr.addstr(2, 1, col_header)
            stdscr.attroff(curses.color_pair(COLOR_COL_HEADER))

            # Number of items in the filsystem list
            #list_uuids = uuids[:max_list_area]
            list_uuids = uuids

            # clamp selection
            if selected_idx >= len(list_uuids):
                selected_idx = max(0, len(list_uuids) - 1)

            # Loop through each filesystem and print out their stats in columns
            for idx, uuid in enumerate(list_uuids):
                label = fs_labels[uuid] if use_labels else uuid
                if len(label) > label_col:
                    label_display = label[: label_col - 3] + "..."
                else:
                    label_display = label

                if uuid in deltas:
                    read_bw = deltas[uuid]["read_bw"]
                    write_bw = deltas[uuid]["write_bw"]
                    read_iops = deltas[uuid]["read_iops"]
                    write_iops = deltas[uuid]["write_iops"]
                # No stats available, initialise to 0
                else:
                    read_bw = write_bw = read_iops = write_iops = 0

                # Update history for all filesystems:
                h = history[uuid]
                h["read_bw"].append(read_bw)
                h["write_bw"].append(write_bw)
                h["read_iops"].append(read_iops)
                h["write_iops"].append(write_iops)
                for k in ["read_bw", "write_bw", "read_iops", "write_iops"]:
                    h[k] = h[k][-CHART_WIDTH:]

                iops_str = f"{read_iops}/{write_iops}"
                line = (
                    f"{label_display:<{label_col}}"
                    f"{format_iec(read_bw):>{COL_READ_BW}}"
                    f"{format_iec(write_bw):>{COL_WRITE_BW}}"
                    f"{iops_str:>{COL_IOPS}}"
                )
                fs_list_ypos = 3 + idx  # Start filesystem list at 3rd row
                if idx == selected_idx:
                    stdscr.attron(curses.color_pair(COLOR_SELECTED))
                    stdscr.addstr(fs_list_ypos, 0, ">" + line)
                    stdscr.attroff(curses.color_pair(COLOR_SELECTED))
                else:
                    stdscr.addstr(fs_list_ypos, 0, " " + line)

            # Draw a horizontal line
            stdscr.attron(curses.color_pair(COLOR_HLINE))
            stdscr.addstr(3 + len(list_uuids), 0, "—" * width)
            stdscr.attroff(curses.color_pair(COLOR_HLINE))

            # Chart details
            chart_start = 4 + len(list_uuids)
            available_for_charts_w = width - 1
            available_for_charts_h = height - 1 - chart_start

            # Selected filesystem's stats should be rendered as charts
            selected_fs = list_uuids[selected_idx]

            # -- Display logic:
            #  1) If IOPS is *not* toggled
            #  2) If IOPS is toggled
            if not show_iops_charts:
                # Show BW charts side-by-side
                if available_for_charts_w >= ((CHART_WIDTH + 11) * 2) and available_for_charts_h >= CHART_HEIGHT:
                    display_chart(
                        stdscr,
                        "Read bytes/sec",
                        history[selected_fs]["read_bw"],
                        chart_start,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_BW_READ,
                        use_iops=False,
                    )
                    display_chart(
                        stdscr,
                        "Write bytes/sec",
                        history[selected_fs]["write_bw"],
                        chart_start,
                        CHART_WIDTH + 12,
                        CHART_WIDTH,
                        COLOR_CHART_BW_WRITE,
                        use_iops=False,
                    )
                # Show BW charts stacked
                elif available_for_charts_w >= (CHART_WIDTH + 11) and available_for_charts_h >= (CHART_HEIGHT * 2):
                    display_chart(
                        stdscr,
                        "Read bytes/sec",
                        history[selected_fs]["read_bw"],
                        chart_start,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_BW_READ,
                        use_iops=False,
                    )
                    display_chart(
                        stdscr,
                        "Write bytes/sec",
                        history[selected_fs]["write_bw"],
                        chart_start + CHART_HEIGHT,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_BW_WRITE,
                        use_iops=False,
                    )
            # Show BW and/or IOPS charts
            else:
                # Show BW + IOPS charts side-by-side
                if available_for_charts_w >= ((CHART_WIDTH + 11) * 4):
                    display_chart(
                        stdscr,
                        "Read bytes/sec",
                        history[selected_fs]["read_bw"],
                        chart_start,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_BW_READ,
                        use_iops=False,
                    )
                    display_chart(
                        stdscr,
                        "Write bytes/sec",
                        history[selected_fs]["write_bw"],
                        chart_start,
                        (CHART_WIDTH + 12),
                        CHART_WIDTH,
                        COLOR_CHART_BW_WRITE,
                        use_iops=False,
                    )
                    display_chart(
                        stdscr,
                        "Read IOPS",
                        history[selected_fs]["read_iops"],
                        chart_start,
                        (CHART_WIDTH + 12) * 2,
                        CHART_WIDTH,
                        COLOR_CHART_IOPS_READ,
                        use_iops=True,
                    )
                    display_chart(
                        stdscr,
                        "Write IOPS",
                        history[selected_fs]["write_iops"],
                        chart_start,
                        (CHART_WIDTH + 12) *3,
                        CHART_WIDTH,
                        COLOR_CHART_IOPS_WRITE,
                        use_iops=True,
                    )
                # Show 2x2 side-by-side
                elif available_for_charts_w >= ((CHART_WIDTH + 11) * 2) and available_for_charts_h >= (CHART_HEIGHT * 2):
                    display_chart(
                        stdscr,
                        "Read bytes/sec",
                        history[selected_fs]["read_bw"],
                        chart_start,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_BW_READ,
                        use_iops=False,
                    )
                    display_chart(
                        stdscr,
                        "Write bytes/sec",
                        history[selected_fs]["write_bw"],
                        chart_start + CHART_HEIGHT,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_BW_WRITE,
                        use_iops=False,
                    )
                    display_chart(
                        stdscr,
                        "Read IOPS",
                        history[selected_fs]["read_iops"],
                        chart_start,
                        CHART_WIDTH + 12,
                        CHART_WIDTH,
                        COLOR_CHART_IOPS_READ,
                        use_iops=True,
                    )
                    display_chart(
                        stdscr,
                        "Write IOPS",
                        history[selected_fs]["write_iops"],
                        chart_start + CHART_HEIGHT,
                        CHART_WIDTH + 12,
                        CHART_WIDTH,
                        COLOR_CHART_IOPS_WRITE,
                        use_iops=True,
                    )
                # Show 4x1 charts stacked.
                elif available_for_charts_w >= (CHART_WIDTH + 11) and available_for_charts_h >= (CHART_HEIGHT * 4):
                    display_chart(
                        stdscr,
                        "Read bytes/sec",
                        history[selected_fs]["read_bw"],
                        chart_start,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_BW_READ,
                        use_iops=False,
                    )
                    display_chart(
                        stdscr,
                        "Write bytes/sec",
                        history[selected_fs]["write_bw"],
                        chart_start + CHART_HEIGHT,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_BW_WRITE,
                        use_iops=False,
                    )
                    display_chart(
                        stdscr,
                        "Read IOPS",
                        history[selected_fs]["read_iops"],
                        chart_start + CHART_HEIGHT * 2,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_IOPS_READ,
                        use_iops=True,
                    )
                    display_chart(
                        stdscr,
                        "Write IOPS",
                        history[selected_fs]["write_iops"],
                        chart_start + CHART_HEIGHT * 3,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_IOPS_WRITE,
                        use_iops=True,
                    )
                # Show 2x1 charts stacked
                elif available_for_charts_w >= (CHART_WIDTH + 11) and available_for_charts_h >= (CHART_HEIGHT * 2):
                    display_chart(
                        stdscr,
                        "Read IOPS",
                        history[selected_fs]["read_iops"],
                        chart_start,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_IOPS_READ,
                        use_iops=True,
                    )
                    display_chart(
                        stdscr,
                        "Write IOPS",
                        history[selected_fs]["write_iops"],
                        chart_start + CHART_HEIGHT,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_IOPS_WRITE,
                        use_iops=True,
                    )
                # Show 1x2 charts side-by-side
                elif available_for_charts_h >= CHART_HEIGHT and available_for_charts_w >= (CHART_WIDTH * 2):
                    display_chart(
                        stdscr,
                        "Read IOPS",
                        history[selected_fs]["read_iops"],
                        chart_start,
                        0,
                        CHART_WIDTH,
                        COLOR_CHART_IOPS_READ,
                        use_iops=True,
                    )
                    display_chart(
                        stdscr,
                        "Write IOPS",
                        history[selected_fs]["write_iops"],
                        chart_start,
                        CHART_WIDTH + 11,
                        CHART_WIDTH,
                        COLOR_CHART_IOPS_WRITE,
                        use_iops=True,
                    )
            stdscr.refresh()
            time.sleep(1)
    except KeyboardInterrupt:
        # Clear screen and exit on Ctrl-C
        stdscr.clear()
###
# End main UI loop
###

def main():
    # Parse CLI arguments
    args = parse_arguments()

    # Get a list of Btrfs filesystems
    btrfs_fs, fs_labels = get_btrfs_filesystems()

    # Display main UI
    curses.wrapper(lambda stdscr: display_ui(stdscr, btrfs_fs, fs_labels))


if __name__ == "__main__":
    main()
