#!/usr/bin/env python3
"""
RTNode-HeltecV4 Flash Utility

Flash the RTNode-HeltecV4 transport node firmware to a Heltec WiFi LoRa 32 V3 or V4.
No PlatformIO required — just Python 3 and a USB cable.

By default, downloads the latest firmware from GitHub Releases (if newer than
the local cache) and flashes the app partition only, preserving bootloader,
partition table, NVS, and EEPROM settings.

Usage:
    # Update firmware — V4 (default)
    python flash.py

    # Update firmware — V3
    python flash.py --board v3

    # Flash a specific release version
    python flash.py --release v1.0.12

    # Full flash with merged binary (overwrites everything)
    python flash.py --full

    # Flash a specific file (auto-detects merged vs app-only)
    python flash.py --file firmware.bin

    # Specify serial port manually
    python flash.py --port /dev/ttyACM0

    # Skip online check — use cached/local firmware only
    python flash.py --offline

    # Just build the merged binary (for GitHub Releases)
    python flash.py --merge-only
"""

import argparse
import glob
import hashlib
import os
import platform
import shutil
import subprocess
import sys
import time

# ── Configuration ──────────────────────────────────────────────────────────────

VERSION         = "1.0.18"
CHIP            = "esp32s3"
FLASH_MODE      = "qio"    # Global default; overridden by board profile
FLASH_FREQ      = "80m"
GITHUB_REPO     = "jrl290/RTNode-HeltecV4"

# Runtime state (set automatically during main())
_flash_mode_override = None   # CLI --flash-mode sets this; otherwise board profile wins

# Flash addresses for ESP32-S3 Arduino framework
BOOTLOADER_ADDR = 0x0000
PARTITIONS_ADDR = 0x8000
BOOT_APP0_ADDR  = 0xe000
APP_ADDR        = 0x10000

# ── Board profiles ─────────────────────────────────────────────────────────────
# Each board defines its PIO env, flash size, baud rate, firmware binary name,
# and merged binary name.

BOARD_PROFILES = {
    "v4": {
        "name":            "Heltec WiFi LoRa 32 V4",
        "pio_env":         "heltec_V4_boundary",
        "build_dir":       ".pio/build/heltec_V4_boundary",
        "firmware_bin":    "rnode_firmware_heltec32v4_boundary.bin",
        "merged_filename": "rtnode_heltec_v4.bin",
        "flash_size":      "16MB",
        "baud_rate":       "921600",
        "flash_mode":      "dio",    # DIO is universally compatible with all flash chips
    },
    "v3": {
        "name":            "Heltec WiFi LoRa 32 V3",
        "pio_env":         "heltec_V3_boundary",
        "build_dir":       ".pio/build/heltec_V3_boundary",
        "firmware_bin":    "rnode_firmware_heltec32v3.bin",
        "merged_filename": "rtnode_heltec_v3.bin",
        "flash_size":      "8MB",
        "baud_rate":       "460800",
        "flash_mode":      "dio",    # V3 uses DIO — some flash chips do not support QIO
    },
}
DEFAULT_BOARD = "v4"

# Active board profile (set in main() from --board arg)
_board = None

def board_profile():
    return BOARD_PROFILES[_board or DEFAULT_BOARD]

def BUILD_DIR():
    return board_profile()["build_dir"]

def BOOTLOADER_BIN():
    return os.path.join(BUILD_DIR(), "bootloader.bin")

def PARTITIONS_BIN():
    return os.path.join(BUILD_DIR(), "partitions.bin")

def FIRMWARE_BIN():
    return os.path.join(BUILD_DIR(), board_profile()["firmware_bin"])

def FLASH_SIZE():
    return board_profile()["flash_size"]

def BAUD_RATE():
    return board_profile()["baud_rate"]

def BOARD_FLASH_MODE():
    """Return the effective flash mode for the current board.

    Priority: CLI override > board profile > global default.
    """
    return _flash_mode_override or board_profile().get("flash_mode", FLASH_MODE)

def MERGED_FILENAME():
    return board_profile()["merged_filename"]

def PIO_ENV():
    return board_profile()["pio_env"]

# ESP32 partition table magic bytes (first two bytes of a partition table entry)
PARTITION_TABLE_MAGIC = b'\xaa\x50'
PARTITION_TABLE_SIZE  = 0xC00   # 3072 bytes


def is_merged_binary(firmware_path):
    """Check whether a firmware file is a merged binary (contains bootloader +
    partition table) or an app-only binary.

    Returns True for merged, False for app-only.
    """
    try:
        size = os.path.getsize(firmware_path)
        if size > 0x8002:
            with open(firmware_path, "rb") as f:
                f.seek(0x8000)
                return f.read(2) == PARTITION_TABLE_MAGIC
    except Exception:
        pass
    return False


def extract_app_from_merged(merged_path):
    """Extract the app-only portion from a merged binary.

    A merged binary starts at 0x0000 and includes bootloader, partition table,
    boot_app0, and the app firmware.  The region between the partition table
    (0x8000-0x8BFF) and boot_app0 (0xE000) contains the NVS partition
    (0x9000-0xDFFF) which is filled with 0xFF padding by esptool merge_bin.
    Flashing a merged binary therefore wipes all saved settings.

    This function extracts bytes from APP_ADDR (0x10000) to the end of the
    file, producing an app-only binary that can be flashed at 0x10000 without
    touching NVS/EEPROM.

    Returns the path to the extracted app-only binary, or None on failure.
    """
    try:
        file_size = os.path.getsize(merged_path)
        if file_size <= APP_ADDR:
            print(f"  Warning: Merged binary too small ({file_size} bytes) to contain app data.")
            return None

        with open(merged_path, "rb") as f:
            f.seek(APP_ADDR)
            app_data = f.read()

        if not app_data:
            return None

        base, ext = os.path.splitext(merged_path)
        app_path = f"{base}_app{ext}"
        with open(app_path, "wb") as f:
            f.write(app_data)

        return app_path
    except Exception as e:
        print(f"  Warning: Could not extract app from merged binary: {e}")
        return None


def _find_in_platformio_or_release(build_path, release_name):
    """Find a file in the PlatformIO build output or the bundled Release/ dir."""
    # 1. PlatformIO build output
    if os.path.isfile(build_path):
        return build_path

    # 2. Bundled in Release/
    bundled = os.path.join(os.path.dirname(__file__), "Release", release_name)
    if os.path.isfile(bundled):
        return bundled

    return None

# Forward-compatible aliases (these are now functions, not constants)
def _bootloader_bin():
    return BOOTLOADER_BIN()

def _partitions_bin():
    return PARTITIONS_BIN()

def _firmware_bin():
    return FIRMWARE_BIN()


def find_boot_app0():
    """Find boot_app0.bin from PlatformIO framework packages.

    Handles versioned package directories (e.g. framework-arduinoespressif32@3.20009.0).
    """
    pio_dir = os.path.expanduser("~/.platformio/packages")

    # Try exact name first
    exact = os.path.join(pio_dir, "framework-arduinoespressif32",
                         "tools", "partitions", "boot_app0.bin")
    if os.path.isfile(exact):
        return exact

    # Try versioned directories
    if os.path.isdir(pio_dir):
        for name in sorted(os.listdir(pio_dir), reverse=True):
            if name.startswith("framework-arduinoespressif32"):
                candidate = os.path.join(pio_dir, name, "tools", "partitions", "boot_app0.bin")
                if os.path.isfile(candidate):
                    return candidate

    # Bundled fallback
    bundled = os.path.join(os.path.dirname(__file__), "Release", "boot_app0.bin")
    if os.path.isfile(bundled):
        return bundled

    return None


def find_bootloader():
    """Find bootloader.bin from PlatformIO build output or Release/ bundle."""
    return _find_in_platformio_or_release(BOOTLOADER_BIN(), "bootloader.bin")


def find_partitions():
    """Find partitions.bin from PlatformIO build output or Release/ bundle."""
    return _find_in_platformio_or_release(PARTITIONS_BIN(), "partitions.bin")


BOOT_APP0_BIN = find_boot_app0()

# ── Board auto-detection ───────────────────────────────────────────────────────

# Map detected flash sizes to board keys
_FLASH_SIZE_TO_BOARD = {
    "16MB": "v4",
    "8MB":  "v3",
}

def detect_board(port, esptool_cmd):
    """Auto-detect which Heltec board is connected by querying flash size.

    Runs ``esptool.py flash_id`` and parses the output for:
      - Detected flash size (16MB → V4, 8MB → V3)
      - Chip type (ESP32-S3 expected)
      - Features (PSRAM size, WiFi, BLE)

    Returns a tuple (board_key, info_dict) on success, or (None, reason) on
    failure.  ``board_key`` is "v3" or "v4".
    """
    cmd = esptool_cmd + ["--port", port, "flash_id"]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
    except subprocess.TimeoutExpired:
        return None, "esptool timed out (device not responding?)"
    except Exception as e:
        return None, str(e)

    output = result.stdout + result.stderr
    if result.returncode != 0:
        return None, f"esptool flash_id failed:\n{output.strip()}"

    # Parse key fields
    info = {}
    for line in output.splitlines():
        line = line.strip()
        if line.startswith("Chip is "):
            info["chip"] = line[len("Chip is "):]
        elif line.startswith("Features:"):
            info["features"] = line[len("Features:"):].strip()
        elif line.startswith("Detected flash size:"):
            info["flash_size"] = line.split(":")[-1].strip()
        elif line.startswith("MAC:"):
            info["mac"] = line.split(":")[-5:]  # last 5 colon-groups
            info["mac"] = line[len("MAC:"):].strip()
        elif line.startswith("Crystal is"):
            info["crystal"] = line[len("Crystal is"):].strip()

    flash_size = info.get("flash_size")
    if not flash_size:
        return None, f"Could not parse flash size from esptool output:\n{output.strip()}"

    board_key = _FLASH_SIZE_TO_BOARD.get(flash_size)
    if not board_key:
        return None, (
            f"Unknown flash size '{flash_size}' — expected 16MB (V4) or 8MB (V3).\n"
            f"Use --board v3 or --board v4 to specify manually."
        )

    return board_key, info


# ── Helpers ────────────────────────────────────────────────────────────────────

def find_esptool():
    """Find esptool — pip-installed, user-local, bundled, or PlatformIO's copy.

    Prefer pip/pipx-installed esptool first (handles its own deps and is
    usually the newest version), then fall back to the bundled script.
    """
    # 1. pip-installed esptool on PATH
    if shutil.which("esptool"):
        return ["esptool"]

    # 2. Common user-local install locations (pip install --user)
    for candidate in [
        os.path.expanduser("~/.local/bin/esptool"),
        os.path.expanduser("~/.local/bin/esptool.py"),
    ]:
        if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
            print(f"  Found user-local esptool: {candidate}")
            return [candidate]

    # Check if pyserial is available before using script-based esptool
    try:
        import serial  # noqa: F401
        has_pyserial = True
    except ImportError:
        has_pyserial = False

    # 2. Bundled in Release/
    bundled = os.path.join(os.path.dirname(__file__), "Release", "esptool", "esptool.py")
    if os.path.isfile(bundled) and has_pyserial:
        return [sys.executable, bundled]

    # 3. PlatformIO's esptool
    pio_esptool = os.path.expanduser(
        "~/.platformio/packages/tool-esptoolpy/esptool.py"
    )
    if os.path.isfile(pio_esptool) and has_pyserial:
        return [sys.executable, pio_esptool]

    # 4. Bundled exists but pyserial is missing — tell the user
    if os.path.isfile(bundled) and not has_pyserial:
        print("Found bundled esptool but pyserial is not installed.")
        print("Install it with:  pip install pyserial")
        print("Or install the standalone esptool:  pip install esptool")
        sys.exit(1)

    return None


def find_serial_port():
    """List available serial ports and let the user choose."""
    system = platform.system()

    # Gather ports from glob patterns
    if system == "Darwin":
        patterns = ["/dev/cu.usbmodem*", "/dev/tty.usbmodem*",
                    "/dev/cu.usbserial*", "/dev/cu.SLAB*"]
    elif system == "Linux":
        patterns = ["/dev/ttyACM*", "/dev/ttyUSB*"]
    else:
        patterns = []

    ports = []
    for pattern in patterns:
        ports.extend(glob.glob(pattern))

    # Also try pyserial's port enumeration (works on all platforms including Windows)
    try:
        import serial.tools.list_ports
        for port in serial.tools.list_ports.comports():
            if port.device not in ports:
                ports.append(port.device)
    except ImportError:
        pass

    # Sort for consistent ordering
    ports.sort()

    if not ports:
        return None

    print("\nAvailable serial ports:")
    for i, p in enumerate(ports):
        print(f"  [{i+1}] {p}")
    print()

    while True:
        try:
            choice = input(f"Select port [1-{len(ports)}]: ").strip()
            idx = int(choice) - 1
            if 0 <= idx < len(ports):
                return ports[idx]
        except (ValueError, EOFError):
            pass
        print("Invalid selection, try again.")


def sha256_file(path):
    """Compute SHA-256 hash of a file."""
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def _cache_dir():
    """Return the firmware cache directory (next to flash.py)."""
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), ".firmware_cache")


def _cache_meta_path(board_key):
    """Return path to the cache metadata JSON for a given board."""
    return os.path.join(_cache_dir(), board_key, "meta.json")


def _cached_firmware_path(board_key):
    """Return path to the cached firmware binary for a given board."""
    return os.path.join(_cache_dir(), board_key, BOARD_PROFILES[board_key]["merged_filename"])


def _read_cache_meta(board_key):
    """Read cache metadata, returning dict or None if not cached."""
    import json
    meta_path = _cache_meta_path(board_key)
    if os.path.isfile(meta_path):
        try:
            with open(meta_path) as f:
                return json.load(f)
        except Exception:
            pass
    return None


def _write_cache_meta(board_key, tag, sha256):
    """Write cache metadata after a successful download."""
    import json
    cache = os.path.join(_cache_dir(), board_key)
    os.makedirs(cache, exist_ok=True)
    meta = {"tag": tag, "sha256": sha256}
    with open(_cache_meta_path(board_key), "w") as f:
        json.dump(meta, f, indent=2)


def _parse_version_tag(tag):
    """Parse a version tag like 'v1.0.13' into a tuple (1, 0, 13) for comparison.
    Returns None if the tag doesn't match the expected format."""
    import re
    m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", tag)
    if m:
        return tuple(int(x) for x in m.groups())
    return None


def _fetch_release_info(tag=None):
    """Fetch release info from GitHub. If tag is None, fetches latest."""
    try:
        from urllib.request import urlopen, Request
        import json
    except ImportError:
        return None, "Python urllib not available"

    if tag:
        # Normalize: ensure tag starts with 'v'
        if not tag.startswith("v"):
            tag = f"v{tag}"
        api_url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/{tag}"
    else:
        api_url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"

    try:
        req = Request(api_url, headers={"Accept": "application/vnd.github+json"})
        with urlopen(req, timeout=10) as resp:
            return json.loads(resp.read()), None
    except Exception as e:
        return None, str(e)


def fetch_firmware(board_key, release_tag=None):
    """Fetch firmware from GitHub, using cache when possible.

    Logic:
      1. Query GitHub for the target release (latest or specific tag).
      2. If the cached firmware matches that release tag, skip download.
      3. Otherwise download the merged firmware binary and update cache.

    Returns (firmware_path, release_tag) on success, (None, reason) on failure.
    """
    from urllib.request import urlretrieve

    merged_name = BOARD_PROFILES[board_key]["merged_filename"]
    cache_path = _cached_firmware_path(board_key)
    cache_meta = _read_cache_meta(board_key)

    # 1. Fetch release info
    label = f"release {release_tag}" if release_tag else "latest release"
    print(f"Checking {label} from {GITHUB_REPO}...")
    release, err = _fetch_release_info(release_tag)
    if not release:
        print(f"  Could not reach GitHub: {err}")
        # Fall back to cache if available
        if cache_meta and os.path.isfile(cache_path):
            print(f"  Using cached firmware: {cache_meta['tag']}")
            return cache_path, cache_meta["tag"]
        return None, f"No cached firmware and GitHub unreachable: {err}"

    remote_tag = release.get("tag_name", "unknown")

    # 2. Check cache
    if cache_meta and os.path.isfile(cache_path):
        cached_tag = cache_meta.get("tag")
        if cached_tag == remote_tag:
            # Verify file integrity
            actual_sha = sha256_file(cache_path)
            if actual_sha == cache_meta.get("sha256"):
                print(f"  Cached firmware is up-to-date: {remote_tag}")
                return cache_path, remote_tag
            else:
                print(f"  Cache integrity mismatch — re-downloading")
        else:
            cached_ver = _parse_version_tag(cached_tag) if cached_tag else None
            remote_ver = _parse_version_tag(remote_tag)
            if cached_ver and remote_ver and remote_ver > cached_ver:
                print(f"  Newer version available: {cached_tag} → {remote_tag}")
            elif cached_ver and remote_ver and remote_ver < cached_ver:
                print(f"  Requested version {remote_tag} is older than cached {cached_tag}")
            else:
                print(f"  Version changed: {cached_tag} → {remote_tag}")

    # 3. Find the asset URL
    asset_url = None
    for asset in release.get("assets", []):
        if asset["name"] == merged_name:
            asset_url = asset["browser_download_url"]
            break

    if not asset_url:
        available = [a["name"] for a in release.get("assets", [])]
        return None, (
            f"'{merged_name}' not found in release {remote_tag}.\n"
            f"  Available assets: {available}"
        )

    # 4. Download
    os.makedirs(os.path.join(_cache_dir(), board_key), exist_ok=True)
    print(f"  Downloading {remote_tag} / {merged_name}...")
    try:
        urlretrieve(asset_url, cache_path)
    except Exception as e:
        return None, f"Download failed: {e}"

    file_sha = sha256_file(cache_path)
    file_size = os.path.getsize(cache_path)
    _write_cache_meta(board_key, remote_tag, file_sha)
    print(f"  Downloaded {file_size:,} bytes  SHA-256: {file_sha[:16]}...")
    return cache_path, remote_tag


def _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmware):
    """Low-level merge: combine the four components into a single binary."""
    print("Merging firmware components...")
    print(f"  Bootloader: {bootloader}  @ 0x{BOOTLOADER_ADDR:04x}")
    print(f"  Partitions: {partitions}  @ 0x{PARTITIONS_ADDR:04x}")
    print(f"  boot_app0:  {boot_app0}   @ 0x{BOOT_APP0_ADDR:04x}")
    print(f"  Firmware:   {firmware}    @ 0x{APP_ADDR:05x}")

    flash_mode = BOARD_FLASH_MODE()
    print(f"  Flash mode: {flash_mode.upper()}")
    cmd = esptool_cmd + [
        "--chip", CHIP,
        "merge_bin",
        "--flash_mode", flash_mode,
        "--flash_freq", FLASH_FREQ,
        "--flash_size", FLASH_SIZE(),
        "-o", output_path,
        f"0x{BOOTLOADER_ADDR:x}", bootloader,
        f"0x{PARTITIONS_ADDR:x}", partitions,
        f"0x{BOOT_APP0_ADDR:x}",  boot_app0,
        f"0x{APP_ADDR:x}",        firmware,
    ]

    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"Error merging: {result.stderr}{result.stdout}")
        return False

    size = os.path.getsize(output_path)
    print(f"\nMerged binary: {output_path} ({size:,} bytes)")
    print(f"SHA-256: {sha256_file(output_path)[:16]}...")
    return True


def merge_firmware(output_path, esptool_cmd):
    """Merge bootloader + partitions + boot_app0 + app into a single binary.

    Uses PlatformIO build output, falling back to bundled Release/ copies
    for the boot components.
    """
    bootloader = find_bootloader()
    partitions = find_partitions()
    boot_app0  = BOOT_APP0_BIN
    firmware   = FIRMWARE_BIN()

    missing = []
    if not bootloader:         missing.append(("bootloader", BOOTLOADER_BIN()))
    if not partitions:         missing.append(("partitions", PARTITIONS_BIN()))
    if not boot_app0:          missing.append(("boot_app0",  "(not found)"))
    if not os.path.isfile(firmware):
        missing.append(("firmware", firmware))

    if missing:
        for name, path in missing:
            print(f"Error: {name} not found: {path}")
        print(f"Run 'pio run -e {PIO_ENV()}' to build first.")
        return False

    return _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmware)


def auto_merge_app_binary(app_binary_path, esptool_cmd):
    """Auto-merge an app-only binary with boot components for a full flash.

    Finds bootloader, partitions, and boot_app0 from PlatformIO build output
    or the bundled Release/ directory, then merges them with the supplied
    app binary into a temporary merged file.

    Returns the path to the merged binary on success, or None on failure.
    """
    bootloader = find_bootloader()
    partitions = find_partitions()
    boot_app0  = BOOT_APP0_BIN

    missing = []
    if not bootloader: missing.append("bootloader.bin")
    if not partitions: missing.append("partitions.bin")
    if not boot_app0:  missing.append("boot_app0.bin")

    if missing:
        print(f"Cannot auto-merge: missing {', '.join(missing)}")
        print("Place them in the Release/ folder alongside flash.py, or")
        print(f"build with PlatformIO: pio run -e {PIO_ENV()}")
        return None

    # Create merged binary next to the app binary
    base, ext = os.path.splitext(app_binary_path)
    merged_path = f"{base}_merged{ext}"

    print("Auto-merging app-only binary with boot components...")
    if _do_merge(merged_path, esptool_cmd, bootloader, partitions, boot_app0, app_binary_path):
        return merged_path
    return None


def read_device_partitions(port, esptool_cmd, baud=None):
    """Read the partition table from the connected device.

    Uses esptool read_flash to read PARTITION_TABLE_SIZE bytes from
    PARTITIONS_ADDR (0x8000).

    Returns the raw bytes on success, or None on failure.
    """
    import tempfile
    if baud is None:
        baud = BAUD_RATE()

    tmp = tempfile.NamedTemporaryFile(suffix=".bin", delete=False)
    tmp.close()
    try:
        cmd = esptool_cmd + [
            "--chip", CHIP,
            "--port", port,
            "--baud", baud,
            "read_flash",
            f"0x{PARTITIONS_ADDR:x}",
            str(PARTITION_TABLE_SIZE),
            tmp.name,
        ]
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        if result.returncode != 0:
            return None
        with open(tmp.name, "rb") as f:
            return f.read()
    except Exception:
        return None
    finally:
        try:
            os.unlink(tmp.name)
        except OSError:
            pass


def check_partition_table(port, esptool_cmd, baud=None):
    """Compare the device's partition table against the expected one.

    Returns:
      True  — partition table matches (or no expected table to compare against)
      False — partition table mismatch (device needs full flash)
    """
    expected_path = find_partitions()
    if not expected_path:
        # Can't check — no reference partition table available
        return True

    with open(expected_path, "rb") as f:
        expected = f.read()

    print("Checking device partition table...")
    device_data = read_device_partitions(port, esptool_cmd, baud)
    if device_data is None:
        print("  Could not read partition table from device")
        # Can't verify — assume OK (user can always use --full)
        return True

    # Compare only the meaningful portion (both should be PARTITION_TABLE_SIZE)
    if device_data[:len(expected)] == expected:
        print("  Partition table OK ✓")
        return True

    # Check if device has any valid partition table at all
    if device_data[:2] != PARTITION_TABLE_MAGIC:
        print("  No valid partition table found on device (blank or corrupted)")
    else:
        print("  Partition table MISMATCH — device has a different layout")

    return False


def check_app_on_device(port, esptool_cmd, baud=None):
    """Check whether app firmware is present on the device.

    Reads a small chunk from APP_ADDR (0x10000).  If the region is all 0xFF
    (erased flash), no app is present and the device needs a full flash.

    Returns True if app firmware is detected, False if blank/absent.
    """
    import tempfile
    if baud is None:
        baud = BAUD_RATE()

    read_size = 256  # enough to distinguish blank from real firmware
    tmp = tempfile.NamedTemporaryFile(suffix=".bin", delete=False)
    tmp.close()
    try:
        cmd = esptool_cmd + [
            "--chip", CHIP,
            "--port", port,
            "--baud", baud,
            "read_flash",
            f"0x{APP_ADDR:x}",
            str(read_size),
            tmp.name,
        ]
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        if result.returncode != 0:
            print("  Warning: Could not read app region from device")
            return True  # assume app exists if we can't check
        with open(tmp.name, "rb") as f:
            data = f.read()
        # All 0xFF means flash is blank — no app present
        if data == b'\xff' * len(data):
            return False
        return True
    except Exception as e:
        print(f"  Warning: App check failed: {e}")
        return True  # assume app exists if we can't check
    finally:
        try:
            os.unlink(tmp.name)
        except OSError:
            pass


def reset_to_bootloader(port):
    """Open serial port at 1200 baud to trigger ESP32-S3 USB bootloader reset.

    Many ESP32-S3 boards with native USB will enter download mode when
    the port is opened at 1200 baud with DTR toggled. This is useful
    when the device is stuck or unresponsive to normal esptool connection.
    """
    try:
        import serial
    except ImportError:
        print("Error: pyserial is required for 1200 baud reset.")
        print("Install it with:  pip install pyserial")
        return False

    print(f"Opening {port} at 1200 baud to trigger bootloader...")
    try:
        ser = serial.Serial(port, 1200)
        ser.dtr = False
        time.sleep(0.1)
        ser.dtr = True
        time.sleep(0.1)
        ser.dtr = False
        ser.close()
    except Exception as e:
        print(f"Error: {e}")
        return False

    print("Waiting for device to re-enumerate in download mode...")
    time.sleep(3)
    print("Done. The device should now be in download mode.")
    return True


def flash_firmware(firmware_path, port, esptool_cmd, baud=None,
                   no_reset_before=False, verify=False,
                   flash_mode=None, no_hard_reset=False):
    """Flash firmware to the device.

    Args:
        no_reset_before: If True, use ``--before no_reset`` so we don't try to
            re-enter download mode (device is already in stub after erase).
        verify: If True, add ``--verify`` for read-back verification.
        flash_mode: Override flash mode (default: board profile).
        no_hard_reset: If True, use ``--after no_reset`` to keep device in stub.
    """
    if baud is None:
        baud = BAUD_RATE()
    flash_size = FLASH_SIZE()
    mode = flash_mode or BOARD_FLASH_MODE()
    print(f"\nFlashing {firmware_path} to {port}...")
    print(f"  Chip: {CHIP}  Baud: {baud}  Flash: {flash_size}  Mode: {mode.upper()}\n")

    # Determine if this is a merged binary (flash at 0x0) or app-only (flash at 0x10000)
    is_merged = is_merged_binary(firmware_path)

    if is_merged:
        flash_addr = f"0x{BOOTLOADER_ADDR:x}"
        print(f"  Detected: merged binary (partition table at 0x8000) -> flash at {flash_addr}")
    else:
        flash_addr = f"0x{APP_ADDR:x}"
        print(f"  Detected: app-only binary -> flash at {flash_addr}")

    before_arg = "no_reset" if no_reset_before else "default-reset"
    after_arg  = "no_reset" if no_hard_reset else "hard-reset"

    cmd = esptool_cmd + [
        "--chip", CHIP,
        "--port", port,
        "--baud", baud,
        "--before", before_arg,
        "--after", after_arg,
        "write-flash",
        "-z",
        "--flash-mode", mode,
        "--flash-freq", FLASH_FREQ,
        "--flash-size", flash_size,
    ]
    if verify:
        cmd.append("--no-diff-verify")
    cmd += [flash_addr, firmware_path]

    print("Running: " + " ".join(cmd[-8:]))
    result = subprocess.run(cmd)
    return result.returncode == 0


def _monitor_boot(port, timeout=8):
    """Open serial port and watch for boot errors for `timeout` seconds.

    Returns:
        (True,  output)  — device appears to be booting normally
        (False, output)  — bootloop detected (ets_loader.c / repeated resets)
        (None,  reason)  — could not open serial port
    """
    try:
        import serial as pyserial
    except ImportError:
        return None, "pyserial not installed — skipping boot check"

    try:
        ser = pyserial.Serial(port, 115200, timeout=1)
    except Exception as e:
        return None, f"Could not open {port}: {e}"

    print(f"\n  Monitoring boot on {port} for {timeout}s...")
    output = ""
    reset_count = 0
    deadline = time.time() + timeout
    try:
        while time.time() < deadline:
            raw = ser.read(ser.in_waiting or 1)
            if raw:
                text = raw.decode("utf-8", errors="replace")
                output += text
                # Count ROM reset lines — 2+ means bootloop
                reset_count += text.count("ets_loader.c")
                if reset_count >= 2:
                    ser.close()
                    return False, output
                # Any application output means boot succeeded
                if "[Boundary]" in output or "RNode" in output or "WiFi" in output:
                    ser.close()
                    return True, output
    except Exception:
        pass
    finally:
        try:
            ser.close()
        except Exception:
            pass

    # If we got reset output but only once, device may still be trying to boot
    if reset_count >= 1 and ("ets_loader.c" in output):
        return False, output

    # No clear signal — assume OK (normal if serial takes time)
    return True, output


# ── Main ───────────────────────────────────────────────────────────────────────

def main():
    global _board
    parser = argparse.ArgumentParser(
        description="RTNode-HeltecV4 Flash Utility — flash transport node firmware to Heltec V3/V4",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python flash.py                         # Download latest & app-only update (V4)
  python flash.py --board v3              # Download latest & app-only update (V3)
  python flash.py --release v1.0.12       # Flash a specific release version
  python flash.py --full                  # Full flash with merged binary
  python flash.py --offline               # Use cached/local firmware only
  python flash.py --file firmware.bin     # Flash a specific file
  python flash.py --merge-only            # Build merged binary for release
  python flash.py --port /dev/ttyACM0     # Specify serial port
  python flash.py --erase                  # Erase flash, then full flash (auto-verify)
        """,
    )
    parser.add_argument("--board", choices=["v3", "v4"], default=None,
                        help="Target board: v3 (Heltec V3) or v4 (Heltec V4). "
                             "Auto-detected from connected device if omitted.")
    parser.add_argument("--file", "-f", help="Path to firmware binary to flash")
    parser.add_argument("--port", "-p", help="Serial port (auto-detected if omitted)")
    parser.add_argument("--baud", "-b", default=None, help="Baud rate (board-specific default)")
    parser.add_argument("--release", "-r", default=None, metavar="TAG",
                        help="Flash a specific release version (e.g. v1.0.12)")
    parser.add_argument("--offline", action="store_true",
                        help="Skip online check — use cached or local firmware only")
    parser.add_argument("--merge-only", action="store_true",
                        help="Merge PlatformIO build output into single binary, don't flash")
    parser.add_argument("--full", action="store_true",
                        help="Flash merged binary (bootloader + partitions + app) — overwrites everything")
    parser.add_argument("--erase", action="store_true",
                        help="Erase entire flash before writing (implies --full)")
    # Power-user override (not shown in --help)
    parser.add_argument("--flash-mode", default=None,
                        help=argparse.SUPPRESS)

    args = parser.parse_args()

    # Find esptool early — needed for both auto-detect and flashing
    esptool_cmd = find_esptool()
    if not esptool_cmd:
        print("Error: esptool not found!")
        print("Install it with:  pip install esptool")
        sys.exit(1)

    # ── Board detection ─────────────────────────────────────────────────
    detected_info = None
    _early_port = None

    if args.board:
        # Explicit board — no detection needed
        _board = args.board
    elif args.merge_only:
        # No device needed for merge — fall back to default
        _board = DEFAULT_BOARD
        print(f"(No --board specified; defaulting to {DEFAULT_BOARD} for merge)")
    else:
        # Auto-detect from connected device
        _early_port = args.port or find_serial_port()
        if not _early_port:
            print("No serial port detected and no --board specified.")
            print(f"Defaulting to {DEFAULT_BOARD}. Specify with --board v3 or --board v4.")
            _board = DEFAULT_BOARD
        else:
            print(f"Detecting board on {_early_port}...")
            board_key, info = detect_board(_early_port, esptool_cmd)
            if board_key:
                _board = board_key
                detected_info = info
                print(f"  Chip:       {info.get('chip', '?')}")
                print(f"  Flash:      {info.get('flash_size', '?')}")
                print(f"  Features:   {info.get('features', '?')}")
                print(f"  MAC:        {info.get('mac', '?')}")
                print(f"  → Detected: {BOARD_PROFILES[board_key]['name']}")
            else:
                reason = info  # info is the error reason when board_key is None
                print(f"  Auto-detect failed: {reason}")
                print(f"  Defaulting to {DEFAULT_BOARD}. Specify with --board v3 or --board v4.")
                _board = DEFAULT_BOARD

    baud = args.baud or BAUD_RATE()
    bp = board_profile()

    print()
    print("╔══════════════════════════════════════════╗")
    print("║    RTNode-HeltecV4 Flash Utility         ║")
    print(f"║  {bp['name']:^40s}  ║")
    print("╚══════════════════════════════════════════╝")
    print()
    print(f"Using esptool: {' '.join(esptool_cmd)}")

    # --erase implies --full (after erase, device needs bootloader + partitions)
    if args.erase:
        args.full = True

    # Apply flash mode override (hidden --flash-mode flag for power users)
    global _flash_mode_override
    if args.flash_mode:
        _flash_mode_override = args.flash_mode

    print(f"  Flash mode: {BOARD_FLASH_MODE().upper()}"
          + (" (override)" if _flash_mode_override else " (board default)"))

    # Determine firmware file
    firmware_path = None
    merged_fn = MERGED_FILENAME()
    firmware_bin = FIRMWARE_BIN()
    pio_env = PIO_ENV()

    if args.file:
        firmware_path = args.file
        if not os.path.isfile(firmware_path):
            print(f"Error: file not found: {firmware_path}")
            sys.exit(1)

    elif args.merge_only:
        if merge_firmware(merged_fn, esptool_cmd):
            print(f"\nDone! Flash with:  python flash.py --board {_board} --file {merged_fn}")
        else:
            sys.exit(1)
        return

    elif args.full and not args.release and args.offline:
        # Full flash, offline: use local PIO build or existing merged binary
        if os.path.isfile(firmware_bin):
            if os.path.isfile(merged_fn):
                build_time = os.path.getmtime(firmware_bin)
                merge_time = os.path.getmtime(merged_fn)
                if build_time > merge_time:
                    print("Build output is newer than merged binary, re-merging...")
                    if not merge_firmware(merged_fn, esptool_cmd):
                        sys.exit(1)
            else:
                print("Creating merged binary from PlatformIO build output...")
                if not merge_firmware(merged_fn, esptool_cmd):
                    sys.exit(1)
            firmware_path = merged_fn
        elif os.path.isfile(merged_fn):
            firmware_path = merged_fn
        else:
            # Try cache
            cached = _cached_firmware_path(_board)
            if os.path.isfile(cached):
                firmware_path = cached
                meta = _read_cache_meta(_board)
                print(f"Using cached firmware: {meta.get('tag', '?') if meta else '?'}")
            else:
                print("No firmware found for full flash!")
                print()
                print("Options:")
                print(f"  1. Build with PlatformIO first:  pio run -e {pio_env}")
                print(f"  2. Run without --offline to download from GitHub")
                print(f"  3. Specify a file:               python flash.py --board {_board} --file <path>")
                sys.exit(1)

    else:
        # Default path: fetch from GitHub (unless --offline)
        if not args.offline:
            fw_path, tag_or_err = fetch_firmware(_board, release_tag=args.release)
            if fw_path:
                firmware_path = fw_path
                print(f"\n  Release: {tag_or_err}")
            else:
                print(f"\n  GitHub: {tag_or_err}")
                print("  Falling back to local firmware...")

        # Fall back to local PIO build output or cache
        if not firmware_path:
            if os.path.isfile(firmware_bin):
                firmware_path = firmware_bin
                print(f"Using local PlatformIO build: {firmware_bin}")
            else:
                cached = _cached_firmware_path(_board)
                if os.path.isfile(cached):
                    firmware_path = cached
                    meta = _read_cache_meta(_board)
                    print(f"Using cached firmware: {meta.get('tag', '?') if meta else '?'}")
                elif os.path.isfile(merged_fn):
                    firmware_path = merged_fn
                    print(f"Using local merged binary: {merged_fn}")
                else:
                    print("No firmware found!")
                    print()
                    print("Options:")
                    print(f"  1. Build with PlatformIO first:  pio run -e {pio_env}")
                    print(f"  2. Specify a file:               python flash.py --board {_board} --file <path>")
                    sys.exit(1)

    # ── Device checks & flash decision ──────────────────────────────────────
    #
    # Flow:
    #   1. --full or --erase on CLI → full_flash = True
    #   2. Check if app firmware exists on device → if not → full_flash = True
    #   3. Check partition table matches expected → if not → full_flash = True
    #   4. Ask user "Erase flash before writing?" → Y → full_flash = True
    #   5. full_flash → flash merged binary at 0x0000
    #   6. Otherwise → extract app from merged, flash at 0x10000

    # Reuse early-detected port, or find one now
    port = args.port or _early_port or find_serial_port()
    if not port:
        print("\nError: No serial port detected!")
        print(f"Connect your {bp['name']} via USB and try again,")
        print(f"or specify manually: python flash.py --board {_board} --port /dev/ttyACM0")
        sys.exit(1)

    print(f"\nSerial port: {port}")
    print(f"Firmware:    {firmware_path} ({os.path.getsize(firmware_path):,} bytes)")

    full_flash = args.full or args.erase

    if not full_flash:
        print("\nChecking device state...")
        has_app = check_app_on_device(port, esptool_cmd, baud)
        if not has_app:
            print("  No app firmware on device — full flash required")
            full_flash = True

    if not full_flash:
        pt_ok = check_partition_table(port, esptool_cmd, baud)
        if not pt_ok:
            print("  Partition table mismatch — full flash required")
            full_flash = True

    if not full_flash:
        try:
            erase_choice = input("\nErase flash before writing? (wipes all settings) [y/N] ").strip().lower()
        except EOFError:
            erase_choice = ""
        if erase_choice == "y":
            full_flash = True

    # ── Prepare firmware based on flash decision ────────────────────────────
    if full_flash:
        # Need the merged binary — ensure we have one
        if not is_merged_binary(firmware_path):
            print("\nCreating merged binary for full flash...")
            merged = auto_merge_app_binary(firmware_path, esptool_cmd)
            if merged:
                firmware_path = merged
            else:
                print("  ERROR: Cannot create merged binary — missing boot components.")
                print(f"  Build with PlatformIO first:  pio run -e {PIO_ENV()}")
                sys.exit(1)

        print(f"\n  Full flash: {os.path.basename(firmware_path)} → 0x{BOOTLOADER_ADDR:04x}")
        print(f"  Size: {os.path.getsize(firmware_path):,} bytes")
        print(f"  ⚠  This will overwrite all settings (NVS/EEPROM)")
    else:
        # Extract app-only from merged binary to preserve settings
        if is_merged_binary(firmware_path):
            app_path = extract_app_from_merged(firmware_path)
            if app_path:
                firmware_path = app_path
            else:
                print("\n  ERROR: Could not extract app from merged binary.")
                sys.exit(1)

        print(f"\n  App-only update: {os.path.basename(firmware_path)} → 0x{APP_ADDR:05x}")
        print(f"  Size: {os.path.getsize(firmware_path):,} bytes")
        print(f"  WiFi/transport settings will be preserved")

    # ── Interactive options ─────────────────────────────────────────────────

    # Offer 1200 baud reset if device might be stuck
    try:
        reset_choice = input("\nReset device to download mode first? (try if device is stuck) [y/N] ").strip().lower()
    except EOFError:
        reset_choice = ""
    if reset_choice == "y":
        reset_to_bootloader(port)
        # Port may change after reset — re-scan
        print("Re-scanning serial ports (port may have changed)...")
        new_port = args.port or find_serial_port()
        if new_port:
            port = new_port
            print(f"Using port: {port}")
        else:
            print(f"Warning: No ports found after reset. Continuing with {port}")

    confirm = input("\nFlash firmware? [Y/n] ").strip().lower()
    if confirm and confirm != "y":
        print("Aborted.")
        sys.exit(0)

    # ── Erase flash (only when --erase was explicitly passed) ───────────────
    erase_performed = False
    if args.erase:
        print(f"\nErasing flash on {port}...")
        # Use --after no_reset so the device stays in the esptool stub after
        # erasing. This avoids exiting download mode (which would require
        # DTR/RTS re-entry and can fail on some USB-UART bridges).
        erase_cmd = esptool_cmd + [
            "--chip", CHIP,
            "--port", port,
            "--baud", baud,
            "--after", "no_reset",
            "erase_flash",
        ]
        result = subprocess.run(erase_cmd)
        if result.returncode != 0:
            print("\nErase FAILED.")
            sys.exit(1)
        erase_performed = True
        print("Flash erased (device still in download mode).")
        time.sleep(1)   # brief settle

    # ── Flash + auto-verify + boot-check + auto-retry ───────────────────────
    #
    # Strategy:
    #   1. Flash with the board's default flash mode
    #   2. If this is a full flash (any path), always add --verify
    #   3. After successful flash+verify, monitor serial for bootloop
    #   4. If bootloop detected and current mode != DIO, auto-retry with DIO
    #
    current_mode = BOARD_FLASH_MODE()

    ok = flash_firmware(firmware_path, port, esptool_cmd, baud,
                        no_reset_before=erase_performed,
                        verify=full_flash)

    if not ok:
        print("\nFlash FAILED. Check connection and try again.")
        print("You may need to hold BOOT while pressing RESET.")
        sys.exit(1)

    # ── Post-flash boot monitoring (on any full flash) ──────────────────────
    if full_flash:
        print("\n  Verifying device boots correctly...")
        time.sleep(2)  # Give device time to start booting
        boot_ok, boot_output = _monitor_boot(port, timeout=8)

        if boot_ok is None:
            # Couldn't open serial — not fatal, just warn
            print(f"  ⚠  {boot_output}")
            print("  Cannot verify boot — check device manually")
        elif boot_ok:
            print("  ✓ Device is booting normally")
        else:
            # Bootloop detected!
            print("\n  ✗ BOOTLOOP DETECTED — device is not booting properly")
            if boot_output:
                # Show the first few relevant lines
                for line in boot_output.splitlines()[:8]:
                    line = line.strip()
                    if line:
                        print(f"    {line}")

            if current_mode != "dio":
                print(f"\n  Current flash mode is {current_mode.upper()} — retrying with DIO...")
                print("  (DIO is more compatible with all flash chip variants)")

                # Need to re-enter download mode: reset via 1200 baud
                print("  Resetting device to download mode...")
                reset_to_bootloader(port)
                time.sleep(3)
                new_port = args.port or find_serial_port()
                if new_port:
                    port = new_port

                # Re-erase if we erased before (flash is garbage after bootloop)
                if args.erase:
                    print(f"\n  Re-erasing flash on {port}...")
                    erase_cmd = esptool_cmd + [
                        "--chip", CHIP,
                        "--port", port,
                        "--baud", baud,
                        "--after", "no_reset",
                        "erase_flash",
                    ]
                    result = subprocess.run(erase_cmd)
                    if result.returncode != 0:
                        print("\n  Re-erase FAILED.")
                        sys.exit(1)
                    erase_performed = True
                    time.sleep(1)

                ok = flash_firmware(firmware_path, port, esptool_cmd, baud,
                                    no_reset_before=erase_performed,
                                    verify=True, flash_mode="dio")

                if not ok:
                    print("\n  DIO retry FAILED.")
                    sys.exit(1)

                # Check boot again
                time.sleep(2)
                boot_ok2, boot_output2 = _monitor_boot(port, timeout=8)
                if boot_ok2 is False:
                    print("\n  ✗ Still bootlooping after DIO retry.")
                    print("  This may be a hardware issue. Check connections and try a different USB cable.")
                    sys.exit(1)
                elif boot_ok2:
                    print("  ✓ Device is booting normally with DIO mode!")
                else:
                    print(f"  ⚠  {boot_output2}")
                    print("  Could not verify boot — check device manually")
            else:
                print("\n  Already using DIO mode — this may be a hardware issue.")
                print("  Try: different USB cable, different port, or reflash the original firmware:")
                print(f"    python flash.py --erase --board {_board}")
                sys.exit(1)

    print()
    print("╔══════════════════════════════════════════╗")
    print("║          Flash complete!                 ║")
    print("║  Device will reboot automatically.       ║")
    print("║                                          ║")
    print("║  On first boot, hold PRG > 5s to enter   ║")
    print("║  the configuration portal.               ║")
    print("╚══════════════════════════════════════════╝")


if __name__ == "__main__":
    main()
