#!/usr/bin/env python3

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

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

- Left-to-right charts; newest sample at LEFT edge.
- Immediate key handling (UI/render loop decoupled from sampling).
- Pause screen updates with 'p' (sampling continues).
- Manual Y-axis per group (BW/IOPS): y/Y adjust, r reset.
- History scrolling with ←/→ (0 = jump to live).
- Configurable sampling interval via --interval (0.1s–60s).
- Configurable history window via --history with minimum retained points.
- Auto width layout that ensures all charts fit; custom width via [ and ].
- Dynamic chart height via { and }.
- X-axis shows seconds-ago; edge timestamps on a separate row.
- Help popup (?), non-flickering; sampling continues while open.
- Status bar: left = help/refresh/history; right = <w>x<h>.
"""

__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.3.0"

import argparse
import curses
import os
import sys
import time

from collections import defaultdict, deque
from math import ceil, floor


####### Configuration Defaults ########

# Colours
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
COLOR_POPUP_BORDER     = 4     # Yellow (reuse)
COLOR_POPUP_TEXT       = 8     # Default FG

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

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

# Chart geometry
DEFAULT_CHART_H   = 11
MIN_CHART_H       = 7
MAX_CHART_H       = 25
DEFAULT_CHART_W   = 35
MIN_CHART_W       = 20
Y_LABEL_PAD       = 11
COL_GAP           = 2

# UI/render loop
UI_FRAME_HZ       = 60
UI_TICK_S         = 1.0 / UI_FRAME_HZ

# Sampling defaults
DEFAULT_INTERVAL_S   = 1.0
DEFAULT_HISTORY_STR  = "60m"
DEFAULT_MIN_POINTS   = 300

# Interval bounds
MIN_INTERVAL_S       = 0.1
MAX_INTERVAL_S       = 60.0

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

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


def init_colors():
	curses.start_color()
	if USE_TERM_COLORS:
		curses.use_default_colors()
		default_bg = -1
	else:
		default_bg = curses.COLOR_BLACK

	curses.init_pair(1, curses.COLOR_BLACK,   default_bg)
	curses.init_pair(2, curses.COLOR_RED,     default_bg)
	curses.init_pair(3, curses.COLOR_GREEN,   default_bg)
	curses.init_pair(4, curses.COLOR_YELLOW,  default_bg)
	curses.init_pair(5, curses.COLOR_BLUE,    default_bg)
	curses.init_pair(6, curses.COLOR_MAGENTA, default_bg)
	curses.init_pair(7, curses.COLOR_CYAN,    default_bg)
	curses.init_pair(8, curses.COLOR_WHITE,   default_bg)


def parse_duration(s: str) -> float:
	s = s.strip().lower()
	try:
		if s.endswith("ms"):
			return float(s[:-2]) / 1000.0
		if s.endswith("s"):
			return float(s[:-1])
		if s.endswith("m"):
			return float(s[:-1]) * 60.0
		if s.endswith("h"):
			return float(s[:-1]) * 3600.0
		return float(s)
	except ValueError:
		raise ValueError(f"Invalid history value '{s}'. Use a number optionally followed by ms, s, m, or h.")


def parse_arguments():
	parser = argparse.ArgumentParser(
		description=__description__,
		formatter_class=lambda prog: argparse.ArgumentDefaultsHelpFormatter(prog, max_help_position=35)
	)
	parser.add_argument(
		"-v", "--version", action="version", version=f"fsmon {__version__}",
		help="Show program version and exit."
	)
	parser.add_argument(
		"-i", "--interval", type=float, default=DEFAULT_INTERVAL_S,
		help="Sampling interval in seconds."
	)
	parser.add_argument(
		"-l", "--history", type=str, default=DEFAULT_HISTORY_STR,
		help="History length (e.g. '300s', '5m', '1h')."
	)
	return parser.parse_args()


def get_btrfs_filesystems():
	btrfs_fs = {}
	labels   = {}
	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)

	for uuid in uuids:
		devices_path = os.path.join(SYSFS_PATH, uuid, "devices")
		label_path   = os.path.join(SYSFS_PATH, uuid, "label")
		if os.path.isdir(devices_path) and os.path.exists(label_path):
			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

			try:
				with open(label_path, "r") as f:
					label = f.read().strip().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

			labels[uuid] = label if label else uuid

	if not btrfs_fs:
		print(f"Error: No Btrfs filesystems found in '{SYSFS_PATH}'"); exit(1)
	return btrfs_fs, labels


def get_device_stats(btrfs_fs):
	fs_stats    = {}
	sector_size = 512
	for uuid, devices in btrfs_fs.items():
		read_bytes = write_bytes = 0
		read_ops = write_ops = 0
		for dev_stat_path in devices:
			try:
				with open(dev_stat_path, "r") as f:
					stats = f.read().split()
				if len(stats) < 7:
					print(f"Warning: Stat file '{dev_stat_path}' is malformed or incomplete.", file=sys.stderr)
					continue
				read_ops     += int(stats[0])
				write_ops    += int(stats[4])
				read_sectors  = int(stats[2])
				write_sectors = int(stats[6])
				read_bytes   += read_sectors * sector_size
				write_bytes  += write_sectors * sector_size
			except FileNotFoundError:
				print(f"Warning: Stat file not found for '{dev_stat_path}'", file=sys.stderr); continue
			except PermissionError:
				print(f"Error: Permission denied when reading '{dev_stat_path}'", file=sys.stderr); continue
			except ValueError:
				print(f"Warning: Invalid data in stat file '{dev_stat_path}'", file=sys.stderr); continue
			except OSError as e:
				print(f"Error: Unable to read '{dev_stat_path}': {e}", file=sys.stderr); exit(1)

		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: float) -> str:
	"""
	IEC units with rollover before 1000 to keep ≤4 glyphs before the unit.
	Examples: 999.9 B → '999.9 B', 1023.9 B → '1.0 KiB', 1536 → '1.5 KiB'
	"""
	units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
	v = float(value)
	for u in units[:-1]:
		if v < 1000.0:
			return f"{v:.1f} {u}"
		v /= 1024.0
	return f"{v:.1f} {units[-1]}"


def format_base10(value):
	prefixes = {'P': 1e15, 'T': 1e12, 'G': 1e9, 'M': 1e6, 'k': 1e3}
	v = float(value)
	for suffix, threshold in prefixes.items():
		if v >= threshold:
			return f"{v / threshold:.1f}{suffix}"
	return f"{v:.1f}"


def _window_data(seq, width, offset):
	arr = list(seq)
	end = max(0, len(arr) - int(offset))
	start = max(0, end - int(width))
	window = arr[start:end]
	if len(window) < width:
		window = [0] * (width - len(window)) + window
	return window


def _format_ts(ts):
	return time.strftime("%H:%M:%S", time.localtime(ts))


def _format_secs_brief(val: float) -> str:
	if val < 600:
		return f"{int(round(val)):d}"
	else:
		m = int(round(val / 60.0))
		return f"{m}m"


def display_chart(stdscr, title, data_seq, ts_seq, row_start, col_start, chart_w, chart_h, color,
                  use_iops=False, offset=0, y_override=None, title_suffix="", interval_s=1.0):
	"""
	Render a single chart with height = chart_h.
	Time increases left → right; newest is drawn at the LEFT edge.
	"""
	window = _window_data(data_seq, chart_w, offset)[::-1]
	ts_win = _window_data(ts_seq or [], chart_w, offset)[::-1]

	left_ts  = next((t for t in ts_win if t), None)
	right_ts = next((t for t in reversed(ts_win) if t), None)

	max_val = max(max(window), 1)
	if y_override and y_override > 0:
		max_val = max(y_override, 1)

	# Title
	full_title = title + (f" {title_suffix}" if title_suffix else "")
	title_x = col_start + Y_LABEL_PAD - 2 + (chart_w // 2) - (len(full_title) // 2)
	title_x = max(col_start, title_x)
	stdscr.addstr(row_start, title_x, full_title)

	# Y rows
	y_axis_rows = max(1, chart_h - 2)
	for i in range(y_axis_rows - 1):
		threshold = max_val * ((y_axis_rows - 1) - i) / (y_axis_rows - 1)
		chart_row = "".join(
			"|" if val > threshold else " " if i == (y_axis_rows - 2) else "."
			for val in window
		).ljust(chart_w - 1)

		y_axis_label = format_base10(threshold) if use_iops else format_iec(threshold)
		stdscr.addstr(row_start + 1 + i, col_start, f"{y_axis_label:>10} ")

		if i == (y_axis_rows - 2):
			stdscr.addstr(row_start + 1 + i, col_start + Y_LABEL_PAD, chart_row,
			              curses.A_UNDERLINE | curses.color_pair(color))
		else:
			stdscr.addstr(row_start + 1 + i, col_start + Y_LABEL_PAD, chart_row,
			              curses.color_pair(color))

	# X-axis ticks
	labels = []
	for col in range(0, chart_w, 5):
		secs = (offset + col) * interval_s
		labels.append(f"{_format_secs_brief(secs):>4}")
	x_axis = " ".join(labels)
	stdscr.addstr(row_start + y_axis_rows, col_start + 8, x_axis[:max(0, chart_w + 8)])

	# Edge timestamps
	if left_ts and right_ts:
		left_label  = _format_ts(left_ts)
		right_label = _format_ts(right_ts)
		stdscr.addstr(row_start + y_axis_rows + 1, col_start + Y_LABEL_PAD, left_label)
		rx = col_start + Y_LABEL_PAD + chart_w - len(right_label) - 1
		if rx > col_start + Y_LABEL_PAD:
			stdscr.addstr(row_start + y_axis_rows + 1, rx, right_label)


def _fit_layout_auto(available_w, available_h, ncharts, chart_h):
	"""
	Auto-layout: find the SMALLEST number of columns that allows all charts to fit,
	while keeping chart width >= MIN_CHART_W. Prefers stacking vertically if height allows.
	"""
	for cols in range(1, ncharts + 1):  # prefer fewer columns (more vertical stacking)
		rows = ceil(ncharts / cols)
		if rows * chart_h > available_h:
			continue
		# width per column
		chart_w = floor((available_w - (cols - 1) * COL_GAP) / cols) - Y_LABEL_PAD
		if chart_w >= MIN_CHART_W:
			return cols, rows, chart_w
	return 0, 0, 0


def _fit_layout_custom(available_w, available_h, ncharts, chart_h, custom_chart_w):
	"""
	Custom width: try from min needed columns upward until it fits height and width.
	"""
	# how many columns can we fit horizontally with the requested width?
	per_col = custom_chart_w + Y_LABEL_PAD
	max_cols = max(1, (available_w + COL_GAP) // (per_col + COL_GAP))
	max_cols = int(min(max_cols, ncharts))

	for cols in range(1, max_cols + 1):  # prefer fewer columns (bigger charts)
		rows = ceil(ncharts / cols)
		if rows * chart_h > available_h:
			continue
		total_w = cols * (custom_chart_w + Y_LABEL_PAD) + (cols - 1) * COL_GAP
		if total_w <= available_w:
			return cols, rows, custom_chart_w
	return 0, 0, 0


def _mmss(seconds: float) -> str:
	seconds = int(round(seconds))
	m, s = divmod(seconds, 60)
	return f"{m}:{s:02d}"


def draw_help_popup(stdscr, lines):
	h, w = stdscr.getmaxyx()
	pad = 4
	content_w = min(max(len(max(lines, key=len)) + 4, 40), max(40, w - 2 * pad))
	content_h = min(len(lines) + 4, max(12, h - 2 * pad))
	start_y = max(1, (h - content_h) // 2)
	start_x = max(1, (w - content_w) // 2)

	win = curses.newwin(content_h, content_w, start_y, start_x)
	win.attron(curses.color_pair(COLOR_POPUP_BORDER)); win.box(); win.attroff(curses.color_pair(COLOR_POPUP_BORDER))

	title = " Help "
	try:
		win.addstr(0, max(1, (content_w - len(title)) // 2), title, curses.color_pair(COLOR_COL_HEADER))
	except curses.error:
		pass

	win.attron(curses.color_pair(COLOR_POPUP_TEXT))
	for i, line in enumerate(lines[: content_h - 3]):
		try:
			win.addstr(2 + i, 2, line[: content_w])
		except curses.error:
			pass
	win.attroff(curses.color_pair(COLOR_POPUP_TEXT))
	win.refresh()
	return win


def display_ui(stdscr, btrfs_fs, fs_labels, sample_interval_s, history_seconds, min_points):
	curses.curs_set(0)
	stdscr.nodelay(True)
	stdscr.keypad(True)
	init_colors()

	selected_idx = 0
	use_labels = True
	show_iops_charts = False

	# Width mode state
	width_mode = "auto"             # "auto" | "custom"
	custom_chart_w = DEFAULT_CHART_W

	# Chart height (dynamic)
	chart_h = DEFAULT_CHART_H

	# History scroll (0 = live)
	scroll_offset = 0

	# Pause (screen updates)
	paused = False

	# Help popup state
	show_help = False
	help_drawn = False
	help_win = None

	prev_stats = {}
	first_skipped = set()

	# History buffers
	history_len = max(min_points, int(history_seconds / sample_interval_s) + 1)
	history = defaultdict(lambda: {
	    "read_bw":    deque(maxlen=history_len),
	    "write_bw":   deque(maxlen=history_len),
	    "read_iops":  deque(maxlen=history_len),
	    "write_iops": deque(maxlen=history_len),
	})
	timeline = deque(maxlen=history_len)

	# Y-scale modes & manual maxima (per group)
	y_mode_bw, y_manual_bw = "auto", 0.0
	y_mode_iops, y_manual_iops = "auto", 0.0

	def active_group():
		return "iops" if show_iops_charts else "bw"

	def current_group_window_max(fs_uuid, chart_w, offset, group):
		if group == "bw":
			rw = _window_data(history[fs_uuid]["read_bw"],  chart_w, offset)
			ww = _window_data(history[fs_uuid]["write_bw"], chart_w, offset)
			return max([0] + rw + ww)
		else:
			ri = _window_data(history[fs_uuid]["read_iops"],  chart_w, offset)
			wi = _window_data(history[fs_uuid]["write_iops"], chart_w, offset)
			return max([0] + ri + wi)

	def ensure_manual_mode(group, fs_uuid, chart_w, offset):
		nonlocal y_mode_bw, y_manual_bw, y_mode_iops, y_manual_iops
		if group == "bw":
			if y_mode_bw == "auto":
				base = current_group_window_max(fs_uuid, chart_w, offset, "bw") or 1.0
				y_manual_bw = float(base); y_mode_bw = "manual"
		else:
			if y_mode_iops == "auto":
				base = current_group_window_max(fs_uuid, chart_w, offset, "iops") or 1.0
				y_manual_iops = float(base); y_mode_iops = "manual"

	def bump_manual(group, factor):
		nonlocal y_manual_bw, y_manual_iops
		if group == "bw":
			y_manual_bw = max(1.0, y_manual_bw * factor)
		else:
			y_manual_iops = max(1.0, y_manual_iops * factor)

	last_sample_time = 0.0
	last_deltas = {}

	try:
		while True:
			# -------- Input handling --------
			while True:
				key = stdscr.getch()
				if key == -1:
					break
				if key == curses.KEY_UP:
					selected_idx = max(0, selected_idx - 1)
				elif key == curses.KEY_DOWN:
					selected_idx += 1
				elif key in (ord('q'), ord('Q')):
					return
				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; scroll_offset = 0
				elif key in (ord('w'), ord('W')):
					# Toggle auto <-> custom
					width_mode = "custom" if width_mode == "auto" else "auto"
				elif key == curses.KEY_LEFT:
					scroll_offset += 5
				elif key == curses.KEY_RIGHT:
					scroll_offset = max(0, scroll_offset - 5)
				elif key in (ord('0'),):
					scroll_offset = 0
				elif key in (ord('['),):
					width_mode = "custom"; custom_chart_w = max(MIN_CHART_W, custom_chart_w - 5)
				elif key in (ord(']'),):
					width_mode = "custom"; custom_chart_w = custom_chart_w + 5
				elif key in (ord('{'),):
					chart_h = max(MIN_CHART_H, chart_h - 1)
				elif key in (ord('}'),):
					chart_h = min(MAX_CHART_H, chart_h + 1)
				elif key in (ord('p'), ord('P')):
					paused = not paused
				elif key in (ord('y'),):  # zoom in (smaller max)
					g = active_group()
					fs_uuid = list(btrfs_fs.keys())[selected_idx] if btrfs_fs else None
					if fs_uuid:
						est_w = custom_chart_w if width_mode == "custom" else DEFAULT_CHART_W
						ensure_manual_mode(g, fs_uuid, est_w, scroll_offset)
						bump_manual(g, 0.90)
				elif key in (ord('Y'),):  # zoom out (larger max)
					g = active_group()
					fs_uuid = list(btrfs_fs.keys())[selected_idx] if btrfs_fs else None
					if fs_uuid:
						est_w = custom_chart_w if width_mode == "custom" else DEFAULT_CHART_W
						ensure_manual_mode(g, fs_uuid, est_w, scroll_offset)
						bump_manual(g, 1.10)
				elif key in (ord('r'), ord('R')):
					if active_group() == "bw":
						y_mode_bw, y_manual_bw = "auto", 0.0
					else:
						y_mode_iops, y_manual_iops = "auto", 0.0
				elif key in (ord('?'), ord('h')):
					show_help = not show_help; help_drawn = False

			# -------- Sampling (independent of pause) --------
			now = time.time()
			if now - last_sample_time >= sample_interval_s:
				current_stats = get_device_stats(btrfs_fs)
				deltas = {}
				for uuid, stats in current_stats.items():
					prev = prev_stats.get(uuid, {"read_bytes": 0, "write_bytes": 0, "read_ops": 0, "write_ops": 0})
					if uuid not in first_skipped:
						first_skipped.add(uuid); prev_stats[uuid] = stats; continue
					inv = 1.0 / sample_interval_s
					deltas[uuid] = {
						"read_bw":    (stats["read_bytes"] - prev["read_bytes"])   * inv,
						"write_bw":   (stats["write_bytes"] - prev["write_bytes"]) * inv,
						"read_iops":  (stats["read_ops"]   - prev["read_ops"])     * inv,
						"write_iops": (stats["write_ops"]  - prev["write_ops"])    * inv,
					}
				prev_stats = current_stats
				if deltas:
					timeline.append(int(now))
					for uuid, d in deltas.items():
						h = history[uuid]
						h["read_bw"].append(d["read_bw"])
						h["write_bw"].append(d["write_bw"])
						h["read_iops"].append(d["read_iops"])
						h["write_iops"].append(d["write_iops"])
					last_deltas = deltas
				last_sample_time = now

			# Clamp selection
			uuids = list(btrfs_fs.keys())
			if selected_idx >= len(uuids):
				selected_idx = max(0, len(uuids) - 1)

			# Clamp scroll
			if timeline:
				max_offset = max(0, len(timeline) - 1)
				if scroll_offset > max_offset:
					scroll_offset = max_offset

			# -------- Help popup --------
			if show_help:
				if not help_drawn:
					help_lines = [
						" ↑ ↓       Select filesystem",
						" L         Toggle labels ↔ UUID",
						" i         Toggle IOPS charts",
						" ← →       Scroll history (±5 cols)",
						" 0         Jump to live",
						" w         Auto ↔ Custom width",
						" [ ]       Custom width −/+ 5 cols (enters Custom)",
						" { }       Adjust chart HEIGHT −/+ 1 row",
						" y / Y     Y-scale: zoom in / out (active group), r reset",
						" p         Pause screen updates",
						" q         Quit",
						" ? / h     Toggle help",
					]
					help_win = draw_help_popup(stdscr, help_lines); help_drawn = True
				#time.sleep(UI_TICK_S)
				continue
			else:
				if help_win is not None:
					help_win.clear(); help_win.refresh(); help_win = None; stdscr.clear()

			# -------- Rendering --------
			if not paused:
				stdscr.erase()
			height, width = stdscr.getmaxyx()
			MIN_HEIGHT = len(uuids) + 4
			if (height < MIN_HEIGHT) or (width < MIN_WIDTH):
				stdscr.addstr(0, 0, f"Terminal too small (need ≥ {MIN_HEIGHT}x{MIN_WIDTH}).")
				stdscr.refresh(); time.sleep(UI_TICK_S); continue

			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))

			# ----- Status bar (left info, right size) -----
			filled_secs = len(timeline) * sample_interval_s
			if paused:
				left = f"Press ? for help  refresh=PAUSED  history={_mmss(filled_secs)}"
			else:
				left = f"Press ? for help  refresh={sample_interval_s:.3g}s  history={_mmss(filled_secs)}"
			right = f"{width}x{height}"
			stdscr.attron(curses.color_pair(COLOR_FOOTER))
			left_max = max(0, width - len(right) - 1)
			stdscr.addstr(height - 1, 0, left[:left_max])
			stdscr.addstr(height - 1, width - 1 - len(right), right)  # keep one-cell indent from corner
			stdscr.attroff(curses.color_pair(COLOR_FOOTER))

			# Label width
			max_label_len = max((len(fs_labels[u]) for u in uuids), default=COL_LABEL) if use_labels else 36
			fixed_cols = COL_READ_BW + COL_WRITE_BW + COL_IOPS
			label_col = min(max_label_len, max(COL_LABEL, width - fixed_cols - 2))

			# Column header
			stdscr.attron(curses.color_pair(COLOR_COL_HEADER))
			stdscr.addstr(2, 1, (f"{'Filesystem':<{label_col}}"
			                     f"{'Read/s':>{COL_READ_BW}}"
			                     f"{'Write/s':>{COL_WRITE_BW}}"
			                     f"{'IOPS(R/W)':>{COL_IOPS}}")[:max(0, width - 2)])
			stdscr.attroff(curses.color_pair(COLOR_COL_HEADER))

			# FS rows
			for idx, uuid in enumerate(uuids):
				label = fs_labels[uuid] if use_labels else uuid
				label_display = label[: label_col - 3] + "..." if len(label) > label_col else label
				if uuid in last_deltas:
					d = last_deltas[uuid]
					read_bw, write_bw = d["read_bw"], d["write_bw"]
					read_iops, write_iops = d["read_iops"], d["write_iops"]
				else:
					read_bw = write_bw = read_iops = write_iops = 0
				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
				if idx == selected_idx:
					stdscr.attron(curses.color_pair(COLOR_SELECTED)); stdscr.addstr(fs_list_ypos, 0, ">" + line[:max(0, width - 2)]); stdscr.attroff(curses.color_pair(COLOR_SELECTED))
				else:
					stdscr.addstr(fs_list_ypos, 0, " " + line[:max(0, width - 2)])

			# Separator
			stdscr.attron(curses.color_pair(COLOR_HLINE)); stdscr.addstr(3 + len(uuids), 0, "-" * (width - 1)); stdscr.attroff(curses.color_pair(COLOR_HLINE))

			# Charts
			if paused:
				stdscr.refresh(); time.sleep(UI_TICK_S); continue

			chart_start = 4 + len(uuids)
			available_w = width - 1
			available_h = height - 1 - chart_start
			if available_h >= chart_h and uuids:
				selected_fs = uuids[selected_idx]
				charts = [
					("Read bytes/sec",  history[selected_fs]["read_bw"],   COLOR_CHART_BW_READ,  False),
					("Write bytes/sec", history[selected_fs]["write_bw"],  COLOR_CHART_BW_WRITE, False),
				]
				if show_iops_charts:
					charts += [
						("Read IOPS",     history[selected_fs]["read_iops"],   COLOR_CHART_IOPS_READ,  True),
						("Write IOPS",    history[selected_fs]["write_iops"],  COLOR_CHART_IOPS_WRITE, True),
					]

				ncharts = len(charts)

				if width_mode == "auto":
					cols, rows, chart_w = _fit_layout_auto(available_w, available_h, ncharts, chart_h)
				else:  # custom
					cols, rows, chart_w = _fit_layout_custom(available_w, available_h, ncharts, chart_h, custom_chart_w)

				if cols == 0:
					stdscr.addstr(chart_start, 0, "Not enough space to render charts.")
				else:
					y_bw_override   = y_manual_bw   if y_mode_bw   == "manual" else None
					y_iops_override = y_manual_iops if y_mode_iops == "manual" else None
					bw_suffix   = f"[Max {format_iec(y_manual_bw)}]" if y_bw_override   else ""
					iops_suffix = f"[Max {format_base10(y_manual_iops)}]" if y_iops_override else ""

					for i, (title, data_seq, color, use_iops) in enumerate(charts):
						r = i // cols
						c = i % cols
						row_y = chart_start + r * chart_h
						col_x = c * (chart_w + Y_LABEL_PAD + COL_GAP)
						display_chart(
							stdscr, title, data_seq, timeline, row_y, col_x, chart_w, chart_h, color,
							use_iops=use_iops, offset=scroll_offset,
							y_override=(y_iops_override if use_iops else y_bw_override),
							title_suffix=(iops_suffix if use_iops else bw_suffix),
							interval_s=sample_interval_s
						)

			stdscr.refresh(); time.sleep(UI_TICK_S)

	except KeyboardInterrupt:
		stdscr.clear()


def main():
	args = parse_arguments()
	sample_interval_s = max(MIN_INTERVAL_S, min(MAX_INTERVAL_S, float(args.interval)))
	try:
		history_seconds = parse_duration(args.history)
	except ValueError as e:
		print(f"Error: {e}", file=sys.stderr)
		sys.exit(1)
	# keep at least the default window so scrolling is useful
	history_seconds = max(parse_duration(DEFAULT_HISTORY_STR), history_seconds)

	btrfs_fs, fs_labels = get_btrfs_filesystems()

	curses.wrapper(lambda stdscr: display_ui(
	    stdscr, btrfs_fs, fs_labels, sample_interval_s, history_seconds, DEFAULT_MIN_POINTS
	))


if __name__ == "__main__":
	main()