#!/usr/bin/env python3
"""Capture an ESP32 boot log after flashing and report whether it came up clean.

Exit codes:
    0 - looks good
    1 - the board is unhappy (crash / reboot loop / missing partition / wrong app)
    2 - could not open the serial port
    3 - inconclusive (no output captured)
"""
import argparse
import re
import sys
import time

CRASH_PATTERNS = [
    (r"Guru Meditation", "CPU exception (Guru Meditation)"),
    (r"assert failed", "assertion failure"),
    (r"abort\(\) was called", "abort() called"),
    (r"CORRUPT HEAP", "heap corruption"),
    (r"Backtrace:", "panic backtrace"),
    (r"invalid header", "invalid image header"),
    (r"no bootable app", "no bootable app partition"),
    (r"[Pp]artition .{0,40}not found", "a partition the app needs is missing"),
]


def capture(port, baud, seconds):
    try:
        import serial
    except ImportError:
        print("verify: pyserial not installed - skipping boot check")
        sys.exit(0)

    # The USB-Serial/JTAG port re-enumerates after the post-flash reset, so the
    # port can be briefly absent. Retry for a few seconds before giving up.
    ser = None
    last_err = ""
    deadline = time.time() + 10
    while time.time() < deadline:
        try:
            ser = serial.Serial(port, baud, timeout=0.5)
            break
        except Exception as exc:
            last_err = str(exc)
            time.sleep(0.3)
    if ser is None:
        print("verify: could not open %s (%s)" % (port, last_err))
        sys.exit(2)

    try:
        # Pulse RTS to reset the board so we catch the boot banner from the top.
        ser.setDTR(False)
        ser.setRTS(True)
        time.sleep(0.1)
        ser.setRTS(False)
    except Exception:
        pass

    buf = b""
    start = time.time()
    while time.time() - start < seconds:
        buf += ser.read(4096)
    ser.close()
    return buf.decode("utf-8", "replace")


def find(pattern, log):
    m = re.search(pattern, log)
    if m:
        return m.group(1)
    return None


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("port", nargs="?", help="serial port, e.g. COM9")
    ap.add_argument("--baud", type=int, default=115200)
    ap.add_argument("--seconds", type=float, default=8.0)
    ap.add_argument("--from-file", help="parse a saved log instead of reading serial (for testing)")
    ap.add_argument("--expect-offset", help="offset we just flashed to, e.g. 0x20000")
    ap.add_argument("--expect-project")
    ap.add_argument("--expect-version")
    ap.add_argument("--expect-idf")
    ap.add_argument("--show-log", action="store_true")
    args = ap.parse_args()

    if args.from_file:
        with open(args.from_file, "r", encoding="utf-8", errors="replace") as fh:
            log = fh.read()
    else:
        if not args.port:
            ap.error("a port is required unless --from-file is given")
        log = capture(args.port, args.baud, args.seconds)

    if args.show_log:
        print(log)

    if not log.strip():
        print("verify: no serial output captured - cannot confirm the board booted")
        print("        (another program may be holding the port, or the board is not running)")
        sys.exit(3)

    problems = []
    notes = []

    # --- did it crash or loop? --------------------------------------------
    resets = len(re.findall(r"rst:0x", log))
    if resets > 1:
        problems.append("reboot loop: %d resets in %.0fs of log" % (resets, args.seconds))

    for pattern, label in CRASH_PATTERNS:
        if re.search(pattern, log):
            problems.append(label)

    # --- what actually booted? --------------------------------------------
    boot_idf = find(r"ESP-IDF (\S+) 2nd stage bootloader", log)
    app_idf = find(r"ESP-IDF:\s+(\S+)", log)
    offset = find(r"Loaded app from partition at offset (0x[0-9a-fA-F]+)", log)
    project = find(r"Project name:\s+(\S+)", log)
    version = find(r"App version:\s+(\S+)", log)

    if boot_idf:
        notes.append("bootloader IDF : %s" % boot_idf)
    if app_idf:
        notes.append("app IDF        : %s" % app_idf)
    if offset:
        notes.append("booted from    : %s" % offset)
    if project:
        notes.append("project        : %s" % project)
    if version:
        notes.append("app version    : %s" % version)

    # --- is the running app the one we just wrote? ------------------------
    if args.expect_offset and offset:
        if int(offset, 16) != int(args.expect_offset, 16):
            problems.append(
                "booted from %s but we flashed %s - the board is running a different app"
                % (offset, args.expect_offset)
            )
    if args.expect_project and project and project != args.expect_project:
        problems.append(
            "running project '%s' but we flashed '%s'" % (project, args.expect_project)
        )
    if args.expect_version and version and version != args.expect_version:
        problems.append(
            "running version '%s' but we flashed '%s'" % (version, args.expect_version)
        )

    # --- bootloader / app version skew is a warning, not a failure --------
    if boot_idf and app_idf and boot_idf != app_idf:
        notes.append(
            "NOTE: bootloader is %s but the app is %s. Usually fine, but if the app "
            "misbehaves, ask for bootloader.bin + partition-table.bin as well."
            % (boot_idf, app_idf)
        )

    # --- runtime errors logged by the app ---------------------------------
    err_lines = [ln.strip() for ln in log.splitlines() if re.match(r"^E \(\d+\)", ln.strip())]
    if err_lines:
        notes.append("%d error line(s) logged by the firmware:" % len(err_lines))
        for ln in err_lines[:5]:
            notes.append("  " + ln)
        if len(err_lines) > 5:
            notes.append("  ... and %d more" % (len(err_lines) - 5))

    # --- verdict -----------------------------------------------------------
    for n in notes:
        print("  " + n)

    if problems:
        print("")
        print("verify: FAILED")
        for p in problems:
            print("  - " + p)
        sys.exit(1)

    if not offset and not project:
        print("")
        print("verify: inconclusive - captured output but found no ESP-IDF boot banner")
        sys.exit(3)

    print("")
    print("verify: board booted cleanly and is running the app we flashed")
    sys.exit(0)


if __name__ == "__main__":
    main()
