#!/usr/bin/env python3
# AUTHOR: Daesorin
# CREATED: 2026-07-12 09:40 +0100
# UPDATED: 2026-07-27 10:53 +0100
 
"""
hledger-charts

terminal and image charts for hledger csv output. reads csv from standard
input, piped directly from hledger, and renders an ascii chart to the
terminal, a matplotlib png, or both.

ARCH DIFFERENCE
matplotlib is only required for --png output, ascii charts use the standard
library alone. install matplotlib via pacman rather than pip where possible:

    pacman -S python-matplotlib

pip installs into an externally managed environment fail under PEP 668
unless a virtualenv is used or --break-system-packages is passed.

INPUT
pipe hledger csv output in directly, or pass --journal so the script
calls hledger itself. pass the -O csv flag when piping directly from
hledger. the --format flag specifies a custom line format string and
produces incompatible text:

    hledger balance -O csv | hledger-charts pie --png pie.png
    hledger-charts pie --journal .journal/2026.journal --png pie.png

histogram and pie read a single period balance report. trend, stacked, and
grouped read a multi period balance report (hledger balance -M), which the
script requests automatically when --journal is given. pass an account pattern
or date range as trailing query arguments to focus the chart. all trailing
arguments and flags after the chart options are passed straight through to
hledger balance:

    hledger-charts stacked --journal .journal/2026.journal expenses
    hledger-charts trend --journal .journal/2026.journal expenses -b 2026-01 -e 2026-04
"""

import argparse
import csv
import io
import os
import re
import subprocess
import sys

SPARK_CHARS = "▁▂▃▄▅▆▇█"
ANSI_COLOURS = [31, 33, 32, 36, 34, 35, 91, 93, 92, 96, 94, 95]


# AMOUNT PARSING
# shared by both single period and multi period csv shapes

def clean_amount(raw, invert=False):
    """EXTRACT THE FIRST NUMERIC VALUE FROM CURRENCY AND FORMATTED STRINGS
    accepts formats such as "1,200.00 NGN", "NGN -1,200.00", "- 450.00"
    """
    cleaned = raw.replace(",", "")
    cleaned = re.sub(r"([-+])\s+", r"\1", cleaned)
    match = re.search(r"[-+]?\d+(?:\.\d+)?", cleaned)
    if not match:
        return 0.0
    val = float(match.group(0))
    return -val if invert else val


# ACCOUNT HIERARCHY

def rollup_account(account, depth):
    """TRUNCATE ACCOUNT NAME TO A SPECIFIED HIERARCHY DEPTH"""
    if not depth or depth <= 0:
        return account
    parts = account.split(":")
    return ":".join(parts[:depth])


def aggregate_single_period(accounts, amounts, depth):
    """GROUP SINGLE PERIOD AMOUNTS BY TRUNCATED ACCOUNT NAME"""
    if not depth or depth <= 0:
        return accounts, amounts
    grouped = {}
    for acc, amt in zip(accounts, amounts):
        parent = rollup_account(acc, depth)
        grouped[parent] = grouped.get(parent, 0.0) + amt
    return list(grouped.keys()), list(grouped.values())


def aggregate_multi_period(accounts, periods, matrix, depth):
    """GROUP MULTI PERIOD MATRICES BY TRUNCATED ACCOUNT NAME"""
    if not depth or depth <= 0:
        return accounts, periods, matrix
    grouped = {}
    n_periods = len(periods)
    for i, acc in enumerate(accounts):
        parent = rollup_account(acc, depth)
        if parent not in grouped:
            grouped[parent] = [0.0] * n_periods
        for j in range(n_periods):
            grouped[parent][j] += matrix[i][j]
    return list(grouped.keys()), periods, list(grouped.values())


# CSV PARSING

def parse_balance_csv(rows, invert=False):
    """PARSE A SINGLE PERIOD BALANCE REPORT
    expects rows shaped like [account, amount], skips headers and totals
    """
    accounts = []
    amounts = []
    for row in rows:
        if len(row) < 2:
            continue
        account = row[0].strip()
        if account.lower().rstrip(" :") in ("account", "total", ""):
            continue
        try:
            amount = clean_amount(row[1], invert=invert)
        except ValueError:
            continue
        accounts.append(account)
        amounts.append(amount)
    return accounts, amounts


def parse_period_csv(rows, invert=False):
    """PARSE A MULTI PERIOD BALANCE REPORT
    expects a header of account,period1,... and drops any trailing total column
    """
    if not rows:
        return [], [], []
    header = [c.strip() for c in rows[0]]
    if len(header) < 2:
        raise ValueError(
            "period csv needs an account column and at least one value column"
        )
    periods = header[1:]
    if len(periods) > 1 and periods[-1].lower().rstrip(" :") == "total":
        periods = periods[:-1]
    accounts = []
    matrix = []
    for row in rows[1:]:
        if len(row) < 2:
            continue
        account = row[0].strip()
        if account.lower().rstrip(" :") in ("account", "total", ""):
            continue
        values = row[1:1 + len(periods)]
        try:
            parsed = [clean_amount(v, invert=invert) for v in values]
        except ValueError:
            continue
        accounts.append(account)
        matrix.append(parsed)
    return accounts, periods, matrix


# DATA LOADING

def load_csv_rows(args, period):
    """LOAD BALANCE REPORT ROWS WITH TTY AND PIPELINE VALIDATION
    calls hledger directly when --journal is given, otherwise reads csv from stdin
    """
    if not args.journal:
        if sys.stdin.isatty():
            print("error: no input piped to stdin and --journal not specified", file=sys.stderr)
            sys.exit(1)
        if args.query:
            print("warning: query arguments are ignored when reading pre-computed csv from stdin", file=sys.stderr)
        return list(csv.reader(sys.stdin))
    cmd = ["hledger", "-f", args.journal, "balance", "-O", "csv"]
    if period:
        cmd.append("-M")
    cmd.extend(args.query)
    try:
        result = subprocess.run(cmd, capture_output=True, text=True)
    except FileNotFoundError:
        print("error: hledger not found on PATH", file=sys.stderr)
        sys.exit(1)
    if result.returncode != 0:
        print(f"error: hledger exited with status {result.returncode}", file=sys.stderr)
        if result.stderr:
            print(result.stderr.strip(), file=sys.stderr)
        sys.exit(1)
    return list(csv.reader(io.StringIO(result.stdout)))


# RANKING HELPERS
# keep charts readable when the account list is long

def top_n_with_other(labels, values, n):
    """COLLAPSE LOW RANKED ENTRIES INTO A SINGLE OTHER CATEGORY"""
    paired = sorted(zip(labels, values), key=lambda p: abs(p[1]), reverse=True)
    if n <= 0 or len(paired) <= n:
        return [p[0] for p in paired], [p[1] for p in paired]
    top = paired[:n]
    rest = paired[n:]
    other_total = sum(v for _, v in rest)
    out_labels = [p[0] for p in top]
    out_values = [p[1] for p in top]
    if other_total != 0:
        out_labels.append("other")
        out_values.append(other_total)
    return out_labels, out_values


def top_n_matrix_with_other(accounts, matrix, n):
    """COLLAPSE LOW RANKED ACCOUNTS INTO AN OTHER ROW ACROSS ALL PERIODS"""
    totals = [sum(abs(v) for v in row) for row in matrix]
    order = sorted(range(len(accounts)), key=lambda i: totals[i], reverse=True)
    if n <= 0 or len(order) <= n:
        idx = order
        return [accounts[i] for i in idx], [matrix[i] for i in idx]
    top_idx = order[:n]
    rest_idx = order[n:]
    top_accounts = [accounts[i] for i in top_idx]
    top_matrix = [matrix[i] for i in top_idx]
    n_periods = len(matrix[0]) if matrix else 0
    other_row = [sum(matrix[i][j] for i in rest_idx) for j in range(n_periods)]
    if any(other_row):
        top_accounts.append("other")
        top_matrix.append(other_row)
    return top_accounts, top_matrix


# ASCII RENDERING PRIMITIVES

def use_colour(args):
    if args.no_colour:
        return False
    if os.environ.get("NO_COLOR"):
        return False
    return sys.stdout.isatty()


def sparkline(values):
    """RENDER A COMPACT UNICODE SPARKLINE FOR ONE SERIES"""
    if not values:
        return ""
    lo = min(values)
    hi = max(values)
    span = hi - lo
    if span == 0:
        return SPARK_CHARS[0] * len(values)
    out = []
    for v in values:
        idx = int(((v - lo) / span) * (len(SPARK_CHARS) - 1))
        out.append(SPARK_CHARS[idx])
    return "".join(out)


def multiline_sparkline(values, rows=2):
    """RENDER A MULTI-LINE ASCII SPARKLINE CANVAS"""
    if not values or rows <= 1:
        return [sparkline(values)]
    lo = min(values)
    hi = max(values)
    span = hi - lo if hi != lo else 1.0
    canvas = [[" " for _ in values] for _ in range(rows)]
    for col, v in enumerate(values):
        norm = (v - lo) / span
        scaled = norm * rows
        full_blocks = int(scaled)
        remainder = scaled - full_blocks
        for r in range(full_blocks):
            if r < rows:
                canvas[rows - 1 - r][col] = "█"
        if full_blocks < rows:
            idx = int(remainder * len(SPARK_CHARS))
            if idx > 0 or full_blocks == 0:
                canvas[rows - 1 - full_blocks][col] = SPARK_CHARS[min(idx, len(SPARK_CHARS) - 1)]
    return ["".join(row) for row in canvas]


def segment_glyph(i, colour):
    """PICK A GLYPH FOR SEGMENT I
    coloured terminals reuse a plain block since colour carries the
    distinction, plain terminals fall back to a letter per segment so
    adjacent segments of the same rendered width stay distinguishable
    """
    if colour:
        return "█"
    return chr(ord("A") + (i % 26))


def render_stacked_bar(labels, values, width, colour):
    """RENDER ONE PROPORTIONAL STACKED BAR ACROSS THE GIVEN WIDTH"""
    abs_values = [abs(v) for v in values]
    total = sum(abs_values)
    if total <= 0:
        return " " * width
    segments = []
    allocated = 0
    for i, v in enumerate(abs_values):
        seg_width = int(round((v / total) * width))
        if i == len(abs_values) - 1:
            seg_width = max(width - allocated, 0)
        allocated += seg_width
        glyph = segment_glyph(i, colour)
        char = glyph * seg_width
        if colour:
            code = ANSI_COLOURS[i % len(ANSI_COLOURS)]
            char = f"\033[{code}m{char}\033[0m"
        segments.append(char)
    return "".join(segments)


def render_legend(labels, values, colour):
    """RENDER A LABEL AND PERCENTAGE LEGEND"""
    abs_values = [abs(v) for v in values]
    total = sum(abs_values)
    max_label = max((len(l) for l in labels), default=0)
    lines = []
    for i, (label, value) in enumerate(zip(labels, values)):
        pct = (abs(value) / total * 100) if total else 0.0
        glyph = segment_glyph(i, colour)
        swatch = glyph
        if colour:
            code = ANSI_COLOURS[i % len(ANSI_COLOURS)]
            swatch = f"\033[{code}m{glyph}\033[0m"
        lines.append(f"{swatch} {label:<{max_label}} {value:>14.2f} {pct:>5.1f}%")
    return "\n".join(lines)


# MATPLOTLIB HELPERS
# imported lazily, ascii output never needs this dependency

def _matplotlib_pyplot(theme="default"):
    try:
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        if theme and theme != "default":
            try:
                plt.style.use(theme)
            except OSError:
                print(f"warning: matplotlib theme '{theme}' not found, falling back to default", file=sys.stderr)
        return plt
    except ImportError:
        print(
            "error: matplotlib not installed, install with "
            "'pacman -S python-matplotlib' or "
            "'pip install matplotlib --break-system-packages'",
            file=sys.stderr,
        )
        sys.exit(1)


def render_histogram_png(accounts, amounts, path, currency, theme):
    plt = _matplotlib_pyplot(theme)
    data = sorted(zip(accounts, amounts), key=lambda p: abs(p[1]), reverse=True)
    labels = [d[0] for d in data]
    values = [d[1] for d in data]
    fig, ax = plt.subplots(figsize=(10, max(4, len(labels) * 0.4)))
    ax.barh(labels, values, color="#4C72B0")
    ax.invert_yaxis()
    ax.set_xlabel(f"amount ({currency})" if currency else "amount")
    ax.set_title("account balances")
    fig.tight_layout()
    fig.savefig(path, dpi=150)
    fig.clf()
    plt.close(fig)
    print(f"saved histogram to {path}", file=sys.stderr)


# MATPLOTLIB HELPERS

def render_pie_png(labels, values, path, currency, theme):
    plt = _matplotlib_pyplot(theme)
    fig, ax = plt.subplots(figsize=(10, 7))
    abs_values = [abs(v) for v in values]
    total = sum(abs_values)

    # format legend labels with percentages
    legend_labels = [
        f"{label} ({(abs(val) / total * 100):.1f}%)" if total else label
        for label, val in zip(labels, values)
    ]

    # suppress autopct on small slices to prevent overlap
    def autopct_fmt(pct):
        return f"{pct:.1f}%" if pct >= 3.0 else ""

    wedges, _, autotexts = ax.pie(
        abs_values,
        autopct=autopct_fmt,
        startangle=90,
        pctdistance=0.75,
    )

    # adjust text contrast based on slice luminance
    for wedge, autotext in zip(wedges, autotexts):
        if autotext.get_text():
            r, g, b = wedge.get_facecolor()[:3]
            luminance = 0.299 * r + 0.587 * g + 0.114 * b
            autotext.set_color("black" if luminance > 0.6 else "white")
            autotext.set_fontsize(9)
            autotext.set_weight("bold")

    ax.legend(
        wedges,
        legend_labels,
        title="accounts",
        loc="center left",
        bbox_to_anchor=(1, 0, 0.5, 1),
        frameon=False,
    )

    ax.set_title(
        f"account distribution ({currency})"
        if currency
        else "account distribution"
    )
    ax.axis("equal")
    fig.tight_layout()
    fig.savefig(path, dpi=150, bbox_inches="tight")
    fig.clf()
    plt.close(fig)
    print(f"saved pie chart to {path}", file=sys.stderr)


def render_trend_png(accounts, periods, matrix, path, currency, theme):
    plt = _matplotlib_pyplot(theme)
    fig, ax = plt.subplots(figsize=(10, 6))
    for i, account in enumerate(accounts):
        ax.plot(periods, matrix[i], marker="o", label=account)
    ax.set_xlabel("period")
    ax.set_ylabel(f"amount ({currency})" if currency else "amount")
    ax.set_title("balance trend")
    ax.legend()
    plt.setp(ax.get_xticklabels(), rotation=45, ha="right")
    fig.tight_layout()
    fig.savefig(path, dpi=150)
    fig.clf()
    plt.close(fig)
    print(f"saved trend chart to {path}", file=sys.stderr)


def render_stacked_png(accounts, periods, matrix, path, currency, theme):
    plt = _matplotlib_pyplot(theme)
    fig, ax = plt.subplots(figsize=(10, 6))
    bottom = [0.0] * len(periods)
    for i, account in enumerate(accounts):
        values = [abs(v) for v in matrix[i]]
        ax.bar(periods, values, bottom=bottom, label=account)
        bottom = [b + v for b, v in zip(bottom, values)]
    ax.set_xlabel("period")
    ax.set_ylabel(f"amount ({currency})" if currency else "amount")
    ax.set_title("spending by category per period")
    ax.legend()
    plt.setp(ax.get_xticklabels(), rotation=45, ha="right")
    fig.tight_layout()
    fig.savefig(path, dpi=150)
    fig.clf()
    plt.close(fig)
    print(f"saved stacked chart to {path}", file=sys.stderr)


def render_grouped_png(accounts, periods, matrix, path, currency, theme):
    plt = _matplotlib_pyplot(theme)
    x = list(range(len(periods)))
    n = len(accounts)
    width = 0.8 / max(n, 1)
    fig, ax = plt.subplots(figsize=(10, 6))
    for i, account in enumerate(accounts):
        values = [abs(v) for v in matrix[i]]
        offset = (i - (n - 1) / 2) * width
        positions = [xi + offset for xi in x]
        ax.bar(positions, values, width=width, label=account)
    ax.set_xticks(x)
    ax.set_xticklabels(periods, rotation=45, ha="right")
    ax.set_xlabel("period")
    ax.set_ylabel(f"amount ({currency})" if currency else "amount")
    ax.set_title("monthly comparison by account")
    ax.legend()
    fig.tight_layout()
    fig.savefig(path, dpi=150)
    fig.clf()
    plt.close(fig)
    print(f"saved grouped chart to {path}", file=sys.stderr)


# SUBCOMMANDS

def cmd_histogram(args):
    rows = load_csv_rows(args, period=False)
    accounts, amounts = parse_balance_csv(rows, invert=args.invert)
    if not accounts:
        print("error: no account data found in input", file=sys.stderr)
        sys.exit(1)
    accounts, amounts = aggregate_single_period(accounts, amounts, args.depth)
    total = sum(amounts)
    abs_total = sum(abs(a) for a in amounts)
    data = sorted(zip(accounts, amounts), key=lambda p: abs(p[1]), reverse=True)
    max_label = max(len(a) for a, _ in data)
    bar_width = 35
    print(f"\n{'ACCOUNT':<{max_label}} | {'AMOUNT':>14} | {'%':>5} | HISTOGRAM")
    print("-" * (max_label + 62))
    for account, amount in data:
        pct = (abs(amount) / abs_total * 100) if abs_total else 0.0
        filled = int((pct / 100) * bar_width)
        print(f"{account:<{max_label}} | {amount:>14.2f} | {pct:>4.1f}% | {'█' * filled}")
    print("-" * (max_label + 62))
    print(f"{'TOTAL':<{max_label}} | {total:>14.2f} | 100.0% |\n")
    if args.png:
        render_histogram_png(accounts, amounts, args.png, args.currency, args.theme)


def cmd_pie(args):
    rows = load_csv_rows(args, period=False)
    accounts, amounts = parse_balance_csv(rows, invert=args.invert)
    if not accounts:
        print("error: no account data found in input", file=sys.stderr)
        sys.exit(1)
    accounts, amounts = aggregate_single_period(accounts, amounts, args.depth)
    labels, values = top_n_with_other(accounts, amounts, args.top)
    colour = use_colour(args)
    print()
    print(render_stacked_bar(labels, values, args.width, colour))
    print()
    print(render_legend(labels, values, colour))
    print()
    if args.png:
        render_pie_png(labels, values, args.png, args.currency, args.theme)


def cmd_trend(args):
    rows = load_csv_rows(args, period=True)
    accounts, periods, matrix = parse_period_csv(rows, invert=args.invert)
    if not accounts:
        print("error: no account data found in input", file=sys.stderr)
        sys.exit(1)
    accounts, periods, matrix = aggregate_multi_period(accounts, periods, matrix, args.depth)
    if args.top > 0:
        accounts, matrix = top_n_matrix_with_other(accounts, matrix, args.top)
    else:
        # DEFAULT TO A SINGLE AGGREGATE SERIES
        # sums every account per period unless --top requests individual lines
        agg = [sum(row[j] for row in matrix) for j in range(len(periods))]
        accounts, matrix = ["total"], [agg]
    colour = use_colour(args)
    max_label = max(len(a) for a in accounts)
    print()
    for i, account in enumerate(accounts):
        series = matrix[i]
        spark_lines = multiline_sparkline(series, rows=args.rows)
        if colour:
            code = ANSI_COLOURS[i % len(ANSI_COLOURS)]
            spark_lines = [f"\033[{code}m{sl}\033[0m" for sl in spark_lines]
        if args.rows > 1:
            print(f"{account}: {series[0]:>12.2f} -> {series[-1]:>12.2f}")
            for sl in spark_lines:
                print(f"  {sl}")
            print()
        else:
            print(f"{account:<{max_label}} {spark_lines[0]}  {series[0]:>12.2f} -> {series[-1]:>12.2f}")
    if args.rows <= 1:
        print()
    print("periods: " + ", ".join(periods))
    print()
    if args.png:
        render_trend_png(accounts, periods, matrix, args.png, args.currency, args.theme)


def cmd_stacked(args):
    rows = load_csv_rows(args, period=True)
    accounts, periods, matrix = parse_period_csv(rows, invert=args.invert)
    if not accounts:
        print("error: no account data found in input", file=sys.stderr)
        sys.exit(1)
    accounts, periods, matrix = aggregate_multi_period(accounts, periods, matrix, args.depth)
    accounts, matrix = top_n_matrix_with_other(accounts, matrix, args.top)
    colour = use_colour(args)
    max_period = max(len(p) for p in periods)
    print()
    for j, period in enumerate(periods):
        values = [abs(matrix[i][j]) for i in range(len(accounts))]
        bar = render_stacked_bar(accounts, values, args.width, colour)
        print(f"{period:<{max_period}} {bar}")
    print()
    totals = [sum(abs(matrix[i][j]) for j in range(len(periods))) for i in range(len(accounts))]
    print(render_legend(accounts, totals, colour))
    print()
    if args.png:
        render_stacked_png(accounts, periods, matrix, args.png, args.currency, args.theme)


def cmd_grouped(args):
    rows = load_csv_rows(args, period=True)
    accounts, periods, matrix = parse_period_csv(rows, invert=args.invert)
    if not accounts:
        print("error: no account data found in input", file=sys.stderr)
        sys.exit(1)
    accounts, periods, matrix = aggregate_multi_period(accounts, periods, matrix, args.depth)
    accounts, matrix = top_n_matrix_with_other(accounts, matrix, args.top)
    colour = use_colour(args)
    bar_width = 20
    print()
    for i, account in enumerate(accounts):
        row = [abs(v) for v in matrix[i]]
        row_max = max(row) if row else 0
        print(account)
        for j, period in enumerate(periods):
            filled = int((row[j] / row_max) * bar_width) if row_max else 0
            char = "█" * filled
            if colour:
                code = ANSI_COLOURS[i % len(ANSI_COLOURS)]
                char = f"\033[{code}m{char}\033[0m"
            padded = char + " " * (bar_width - filled)
            print(f"  {period:<10} {padded} {row[j]:>12.2f}")
        print()
    if args.png:
        render_grouped_png(accounts, periods, matrix, args.png, args.currency, args.theme)


# ARGUMENT PARSING

def build_parser():
    parser = argparse.ArgumentParser(
        description="terminal and image charts for hledger csv output, reads csv from standard input"
    )
    sub = parser.add_subparsers(dest="command", required=True)

    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--png", metavar="PATH", help="also render a matplotlib png to this path")
    common.add_argument("-t", "--theme", default="default", help="matplotlib stylesheet theme (e.g., dark_background, ggplot)")
    common.add_argument("--currency", default="", help="currency label for chart titles")
    common.add_argument("--no-colour", action="store_true", help="disable ansi colour output")
    common.add_argument("-i", "--invert", action="store_true", help="invert mathematical signs (multiply all amounts by -1)")
    common.add_argument("-d", "--depth", type=int, default=0, help="summarise accounts to this hierarchy depth (0 for full names)")
    common.add_argument("--journal", metavar="PATH", help="hledger journal file, calls hledger directly instead of reading csv from stdin")
    common.add_argument("query", nargs=argparse.REMAINDER, help="optional hledger query passed through to hledger balance, e.g. an account pattern or -b/-e dates, only used with --journal")

    p_hist = sub.add_parser("histogram", parents=[common], help="horizontal bar histogram of account balances")
    p_hist.set_defaults(func=cmd_histogram)

    p_pie = sub.add_parser("pie", parents=[common], help="proportional bar and pie chart of account distribution")
    p_pie.add_argument("--top", type=int, default=8, help="accounts to show before collapsing into other, 0 for no limit")
    p_pie.add_argument("--width", type=int, default=60, help="width of the ascii bar in characters")
    p_pie.set_defaults(func=cmd_pie)

    p_trend = sub.add_parser("trend", parents=[common], help="balance trend over time as sparklines and a line chart")
    p_trend.add_argument("--top", type=int, default=0, help="individual account lines to show, 0 shows only the total")
    p_trend.add_argument("-r", "--rows", type=int, default=1, help="height of the ascii sparkline canvas in rows (default 1)")
    p_trend.set_defaults(func=cmd_trend)

    p_stack = sub.add_parser("stacked", parents=[common], help="stacked bar of spending by category per period")
    p_stack.add_argument("--top", type=int, default=8, help="accounts to show before collapsing into other, 0 for no limit")
    p_stack.add_argument("--width", type=int, default=50, help="width of each ascii bar in characters")
    p_stack.set_defaults(func=cmd_stacked)

    p_group = sub.add_parser("grouped", parents=[common], help="grouped bar comparison of accounts across periods")
    p_group.add_argument("--top", type=int, default=6, help="accounts to show before collapsing into other, 0 for no limit")
    p_group.set_defaults(func=cmd_grouped)

    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()
