#!/usr/bin/env python3
"""Convert a freeze-watcher text snapshot to JSON and/or HTML.

Usage:
    freeze-snapshot-convert PATH.log [--json] [--html]

Output files are written alongside the input with .json / .html suffixes.
"""

import argparse
import html as html_lib
import json
import re
import sys
from pathlib import Path


# --------------------------------------------------------------------- parser

def parse_snapshot(text):
    """Parse a freeze-watcher text snapshot into a structured dict."""
    data = {
        "trigger_reason": "",
        "timestamp": "",
        "diagnoses": [],
        "loadavg": {},
        "procs_running": None,
        "procs_blocked": None,
        "psi": {},
        "cgroup_psi": {"io": [], "cpu": []},
        "dstate_processes": [],
        "kernel_stacks": {},
        "top_cpu": [],
        "top_rss": [],
        "memory": {},
        "iostat_raw": "",
        "interrupts": [],
        "softirqs": {},
        "dmesg_recent": [],
        "journal_warnings": [],
    }

    sections = _split_sections(text)

    # Header
    for ln in sections.get("__header__", []):
        m = re.match(r"=== FREEZE EVENT TRIGGERED:\s*(.+?)\s*===", ln)
        if m:
            data["trigger_reason"] = m.group(1)
        m = re.match(r"Time:\s*(.+)", ln)
        if m:
            data["timestamp"] = m.group(1).strip()

    # Diagnosis
    for ln in sections.get("Diagnosis (auto-classified)", []):
        s = ln.strip()
        if s.startswith("•"):
            data["diagnoses"].append(s.lstrip("• ").strip())

    # /proc/loadavg
    for ln in sections.get("/proc/loadavg", []):
        parts = ln.split()
        if len(parts) >= 3:
            try:
                data["loadavg"] = {"1m": float(parts[0]), "5m": float(parts[1]), "15m": float(parts[2])}
            except ValueError:
                pass
            break

    # /proc/stat
    for ln in sections.get("/proc/stat (procs_running, procs_blocked)", []):
        m = re.match(r"procs_running\s+(\d+)", ln)
        if m:
            data["procs_running"] = int(m.group(1))
        m = re.match(r"procs_blocked\s+(\d+)", ln)
        if m:
            data["procs_blocked"] = int(m.group(1))

    # PSI
    for ln in sections.get("PSI cpu/io/memory", []):
        m = re.match(r"\[(\w+)\]", ln)
        if m:
            current_psi = m.group(1)
            data["psi"][current_psi] = {}
            continue
        m = re.match(r"(some|full)\s+(.+)", ln)
        if m and "psi" in data and data["psi"]:
            kind = m.group(1)
            kvs = dict(re.findall(r"(\w+)=([\d.]+)", m.group(2)))
            last_key = list(data["psi"].keys())[-1]
            data["psi"][last_key][kind] = {k: float(v) for k, v in kvs.items()}

    # Cgroup PSI
    for header, key in [("Top cgroups by PSI io.some avg10", "io"),
                         ("Top cgroups by PSI cpu.some avg10", "cpu")]:
        for ln in sections.get(header, []):
            ln = ln.strip()
            if not ln:
                continue
            parts = ln.split(None, 1)
            if len(parts) == 2:
                try:
                    data["cgroup_psi"][key].append({"value": float(parts[0]), "cgroup": parts[1]})
                except ValueError:
                    pass

    # D-state processes
    for ln in sections.get("D-state processes (with kernel wchan)", []):
        parts = ln.split(None, 5)
        if len(parts) >= 5 and parts[0] == "D":
            entry = {
                "pid": int(parts[1]) if parts[1].isdigit() else parts[1],
                "user": parts[2],
                "wchan": parts[3],
                "comm": parts[4],
                "args": parts[5] if len(parts) > 5 else "",
            }
            data["dstate_processes"].append(entry)

    # Kernel stacks
    current_pid = None
    for ln in sections.get("Kernel stack traces of D-state procs", []):
        m = re.match(r"--- pid=(\d+) comm=(\S+) ---", ln)
        if m:
            current_pid = int(m.group(1))
            data["kernel_stacks"][current_pid] = {"comm": m.group(2), "stack": []}
        elif current_pid and ln.strip().startswith("[<"):
            data["kernel_stacks"][current_pid]["stack"].append(ln.strip())
        elif current_pid and ln.strip():
            data["kernel_stacks"][current_pid]["stack"].append(ln.strip())

    # Top CPU
    for ln in sections.get("Top 15 by CPU", []):
        parts = ln.split()
        if len(parts) >= 6 and parts[1] != "user":
            try:
                data["top_cpu"].append({
                    "pid": int(parts[0]), "user": parts[1],
                    "pcpu": float(parts[2]), "pmem": float(parts[3]),
                    "state": parts[4], "comm": parts[5],
                })
            except ValueError:
                pass

    # Top RSS
    for ln in sections.get("Top 15 by RSS memory", []):
        parts = ln.split()
        if len(parts) >= 6 and parts[1] != "user":
            try:
                data["top_rss"].append({
                    "pid": int(parts[0]), "user": parts[1],
                    "pcpu": float(parts[2]), "pmem": float(parts[3]),
                    "rss_kb": int(parts[4]), "comm": parts[5],
                })
            except ValueError:
                pass

    # /proc/meminfo
    for ln in sections.get("/proc/meminfo summary", []):
        m = re.match(r"(\w+):\s+(\d+)\s*kB", ln)
        if m:
            data["memory"][m.group(1)] = int(m.group(2))

    # iostat raw
    data["iostat_raw"] = "\n".join(sections.get("iostat (1s snapshot)", []))

    # Interrupts (top by total)
    for ln in sections.get("interrupts (top by total)", []):
        parts = ln.split()
        if len(parts) >= 3 and parts[0].isdigit():
            data["interrupts"].append({
                "total": int(parts[0]),
                "raw": " ".join(parts[1:]),
            })

    # softirqs - keep raw
    softirq_lines = sections.get("softirq", [])
    if softirq_lines:
        cpu_header = softirq_lines[0].split() if softirq_lines else []
        for ln in softirq_lines[1:]:
            m = re.match(r"\s*(\w+):\s+(.+)", ln)
            if m:
                vals = m.group(2).split()
                try:
                    data["softirqs"][m.group(1)] = [int(v) for v in vals]
                except ValueError:
                    pass

    data["dmesg_recent"] = sections.get("Recent dmesg (last 50 lines)", [])
    data["journal_warnings"] = sections.get("journal warnings/errors last 60s", [])

    return data


def _split_sections(text):
    """Split a snapshot into sections keyed by '## Header' titles."""
    sections = {"__header__": []}
    current = "__header__"
    for ln in text.splitlines():
        m = re.match(r"## (.+)", ln)
        if m:
            current = m.group(1).strip()
            sections[current] = []
        else:
            sections[current].append(ln)
    return sections


# --------------------------------------------------------------------- HTML

def render_html(data, source_text):
    def metric_class(value, warn, crit):
        if value is None:
            return "ok"
        if value >= crit:
            return "crit"
        if value >= warn:
            return "warn"
        return "ok"

    def fmt_psi(kind, key="some"):
        block = data["psi"].get(kind, {}).get(key, {})
        return block.get("avg10", 0.0)

    psi_io = fmt_psi("io")
    psi_cpu = fmt_psi("cpu")
    psi_mem = fmt_psi("memory")
    load_1m = data["loadavg"].get("1m", 0)

    diag_html = "".join(f"<li>{html_lib.escape(d)}</li>" for d in data["diagnoses"]) \
                 or "<li><i>uncertain</i></li>"

    dstate_html = ""
    for p in data["dstate_processes"]:
        dstate_html += (
            f'<div class="dstate-proc">'
            f'<b>pid {p["pid"]}</b> ({html_lib.escape(p["comm"])}) '
            f'user={html_lib.escape(p["user"])} '
            f'<code>wchan={html_lib.escape(p["wchan"])}</code>'
        )
        if p["pid"] in data["kernel_stacks"]:
            stack = "\n".join(data["kernel_stacks"][p["pid"]]["stack"])
            dstate_html += f'<details><summary>kernel stack</summary><pre>{html_lib.escape(stack)}</pre></details>'
        dstate_html += "</div>"
    if not dstate_html:
        dstate_html = "<p><i>None at capture time.</i></p>"

    top_cpu_rows = "".join(
        f"<tr><td>{p['pid']}</td><td>{html_lib.escape(p['user'])}</td>"
        f"<td>{p['pcpu']}</td><td>{p['pmem']}</td>"
        f"<td>{p['state']}</td><td>{html_lib.escape(p['comm'])}</td></tr>"
        for p in data["top_cpu"][:10]
    )

    top_rss_rows = "".join(
        f"<tr><td>{p['pid']}</td><td>{html_lib.escape(p['user'])}</td>"
        f"<td>{p['rss_kb']/1024:.0f} MB</td>"
        f"<td>{html_lib.escape(p['comm'])}</td></tr>"
        for p in data["top_rss"][:10]
    )

    cg_io_rows = "".join(
        f"<tr><td>{c['value']}</td><td><code>{html_lib.escape(c['cgroup'])}</code></td></tr>"
        for c in data["cgroup_psi"]["io"][:10]
    ) or "<tr><td colspan=2><i>none</i></td></tr>"

    cg_cpu_rows = "".join(
        f"<tr><td>{c['value']}</td><td><code>{html_lib.escape(c['cgroup'])}</code></td></tr>"
        for c in data["cgroup_psi"]["cpu"][:10]
    ) or "<tr><td colspan=2><i>none</i></td></tr>"

    return f"""<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<title>Freeze snapshot — {html_lib.escape(data['timestamp'])}</title>
<style>
:root {{ color-scheme: dark; }}
body {{ font-family: -apple-system, sans-serif; max-width: 1200px;
       margin: 2em auto; padding: 1em; background: #1a1a1a; color: #ddd; }}
h1 {{ color: #88ccff; margin-bottom: 0; }}
h2 {{ color: #88ccff; border-bottom: 1px solid #333; padding-bottom: 0.3em; }}
.subtitle {{ color: #888; margin-top: 0.3em; }}
.diagnosis {{ background: #2a2a3a; padding: 1em 1.5em; border-left: 4px solid #f5a623;
              margin: 1em 0; border-radius: 4px; }}
.diagnosis ul {{ margin: 0.3em 0; padding-left: 1.5em; }}
.diagnosis li {{ margin: 0.3em 0; }}
.metric {{ display: inline-block; padding: 0.6em 1em; margin: 0.3em;
           border-radius: 6px; font-weight: bold; min-width: 6em; text-align: center; }}
.metric.warn {{ background: #665022; color: #ffd866; }}
.metric.crit {{ background: #5a2222; color: #ff8888; }}
.metric.ok   {{ background: #2d5a3d; color: #88ddaa; }}
.metric .label {{ display: block; font-size: 0.75em; opacity: 0.8; font-weight: normal; }}
section {{ margin: 1.5em 0; padding: 1em 1.5em; background: #222; border-radius: 6px; }}
pre {{ white-space: pre-wrap; font-family: monospace; font-size: 0.85em;
       background: #111; padding: 0.8em; border-radius: 4px; overflow-x: auto; }}
table {{ border-collapse: collapse; width: 100%; font-size: 0.9em; }}
th, td {{ padding: 0.4em 0.8em; text-align: left; border-bottom: 1px solid #333; }}
th {{ background: #2a2a2a; color: #88ccff; }}
code {{ background: #111; padding: 0.1em 0.4em; border-radius: 3px; color: #ffcc66; }}
.dstate-proc {{ background: #2a1f1f; padding: 0.7em 1em; margin: 0.5em 0;
                border-radius: 4px; border-left: 3px solid #ff5555; }}
details summary {{ cursor: pointer; padding: 0.4em 0; color: #88ccff; }}
details[open] summary {{ margin-bottom: 0.5em; }}
</style>
</head><body>
<h1>Freeze snapshot</h1>
<p class="subtitle">{html_lib.escape(data['timestamp'])} —
   trigger: <code>{html_lib.escape(data['trigger_reason'])}</code></p>

<div class="diagnosis">
  <h2 style="border:none; margin-top:0;">Diagnosis</h2>
  <ul>{diag_html}</ul>
</div>

<section>
<h2>System pressure</h2>
<div>
  <span class="metric {metric_class(psi_io, 30, 60)}">{psi_io:.1f}%<span class="label">PSI io.some</span></span>
  <span class="metric {metric_class(psi_cpu, 30, 60)}">{psi_cpu:.1f}%<span class="label">PSI cpu.some</span></span>
  <span class="metric {metric_class(psi_mem, 20, 50)}">{psi_mem:.1f}%<span class="label">PSI mem.some</span></span>
  <span class="metric {metric_class(load_1m, 8, 16)}">{load_1m:.2f}<span class="label">load 1m</span></span>
  <span class="metric {metric_class(len(data['dstate_processes']), 3, 6)}">{len(data['dstate_processes'])}<span class="label">D-state procs</span></span>
  <span class="metric {metric_class(data['procs_blocked'], 3, 6)}">{data['procs_blocked'] or 0}<span class="label">procs_blocked</span></span>
</div>
</section>

<section>
<h2>D-state processes — where they're stuck</h2>
{dstate_html}
</section>

<section>
<h2>Top processes by CPU</h2>
<table>
<tr><th>PID</th><th>User</th><th>%CPU</th><th>%MEM</th><th>State</th><th>Command</th></tr>
{top_cpu_rows}
</table>
</section>

<section>
<h2>Top processes by RSS memory</h2>
<table>
<tr><th>PID</th><th>User</th><th>RSS</th><th>Command</th></tr>
{top_rss_rows}
</table>
</section>

<section>
<h2>Top cgroups by io.pressure</h2>
<table><tr><th>avg10 %</th><th>cgroup</th></tr>{cg_io_rows}</table>
<h2 style="margin-top:1em;">Top cgroups by cpu.pressure</h2>
<table><tr><th>avg10 %</th><th>cgroup</th></tr>{cg_cpu_rows}</table>
</section>

<details>
<summary>Raw snapshot text (full contents)</summary>
<pre>{html_lib.escape(source_text)}</pre>
</details>
</body></html>
"""


# --------------------------------------------------------------------- main

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("snapshot", help="Path to snap-*.log file")
    ap.add_argument("--json", action="store_true", help="Write JSON sidecar")
    ap.add_argument("--html", action="store_true", help="Write HTML sidecar")
    args = ap.parse_args()

    if not (args.json or args.html):
        ap.error("Specify --json and/or --html")

    src = Path(args.snapshot)
    text = src.read_text(errors="replace")
    data = parse_snapshot(text)

    if args.json:
        out = src.with_suffix(".json")
        out.write_text(json.dumps(data, indent=2))
        print(f"wrote {out}", file=sys.stderr)

    if args.html:
        out = src.with_suffix(".html")
        out.write_text(render_html(data, text))
        print(f"wrote {out}", file=sys.stderr)


if __name__ == "__main__":
    main()
