#!/usr/bin/env python3
"""Render a sensor dump as a heatmap.

Usage:
    python heatmap.py [input.txt] [-o out.png] [--cmap inferno]

Accepts either:
  - Raw device log lines as printed by the firmware, e.g.:
        5.464019 [INFO ] row 0: [71.6, 75.2, ..., 82.1] (tts_test tts-test/src/main.rs:212)
    Only the "row N: [...]" part of each line is used; everything else
    (timestamps, log level, source location, other info! lines like the
    ambient temperature/heatmap range) is ignored. You can paste the whole
    console log as-is.
  - Plain rows of numbers, one row per line, brackets/commas optional:
        [55, 192, 131, ..., 138]
"""

import argparse
import re
import sys

_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
_ROW_RE = re.compile(r"row\s*(\d+)\s*:\s*\[([^\]]*)\]", re.IGNORECASE)


def parse_grid(text):
    """Parse a device log or a plain number dump into a 2D list of floats."""
    text = _ANSI_RE.sub("", text)

    # Prefer explicit "row N: [...]" entries (as printed by the firmware's
    # info! logs) so that timestamps, log levels and source locations on the
    # same line don't get mistaken for pixel data.
    rows = {}
    for match in _ROW_RE.finditer(text):
        idx = int(match.group(1))
        nums = re.findall(r"-?\d+(?:\.\d+)?", match.group(2))
        if nums:
            rows[idx] = [float(n) for n in nums]

    if rows:
        grid = [rows[i] for i in sorted(rows)]
    else:
        # fall back: one row of numbers per line
        grid = []
        for line in text.splitlines():
            nums = re.findall(r"-?\d+(?:\.\d+)?", line)
            if nums:
                grid.append([float(n) for n in nums])

    if not grid:
        raise ValueError("No numeric data found in input")
    width = len(grid[0])
    if any(len(row) != width for row in grid):
        raise ValueError("Rows have inconsistent lengths")
    return grid


def main():
    parser = argparse.ArgumentParser(description="Render a number grid as a heatmap.")
    parser.add_argument("input", nargs="?", default="output.txt",
                        help="input file (default: output.txt)")
    parser.add_argument("-o", "--output",
                        help="save figure to this path instead of showing it")
    parser.add_argument("--cmap", default="inferno", help="matplotlib colormap")
    parser.add_argument("--no-annot", action="store_true",
                        help="do not print the value inside each cell")
    args = parser.parse_args()

    import matplotlib.pyplot as plt

    try:
        with open(args.input) as f:
            grid = parse_grid(f.read())
    except (OSError, ValueError) as e:
        sys.exit(f"error: {e}")

    rows, cols = len(grid), len(grid[0])

    fig, ax = plt.subplots(figsize=(max(6, cols * 0.5), max(5, rows * 0.5)))
    im = ax.imshow(grid, cmap=args.cmap, interpolation="nearest")
    fig.colorbar(im, ax=ax, label="value")
    ax.set_title(f"{args.input}  ({rows}x{cols})")
    ax.set_xticks(range(cols))
    ax.set_yticks(range(rows))

    if not args.no_annot:
        vmin, vmax = min(map(min, grid)), max(map(max, grid))
        mid = (vmin + vmax) / 2
        for r in range(rows):
            for c in range(cols):
                v = grid[r][c]
                ax.text(c, r, f"{v:g}", ha="center", va="center", fontsize=6,
                        color="white" if v < mid else "black")

    fig.tight_layout()
    if args.output:
        fig.savefig(args.output, dpi=150)
        print(f"saved {args.output}")
    else:
        plt.show()


if __name__ == "__main__":
    main()
