#!/usr/bin/env python3
"""Dry-run-first helpers for local Bambu Lab print workflows.

The default behavior is non-networked. Start from a validated plain .gcode file,
then either upload/start it directly with the printer's gcode_file command or,
for explicitly supported printer profiles, package it with bambox into a
.gcode.3mf project and start that with project_file.
"""

from __future__ import annotations

import argparse
import ftplib
import hashlib
import ipaddress
import json
import os
import re
import shutil
import socket
import ssl
import subprocess
import sys
import tempfile
import time
import zipfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Union


BAMBU_USERNAME = "bblp"
DEFAULT_FTPS_PORT = 990
DEFAULT_MQTT_PORT = 8883
DEFAULT_REMOTE_DIR = "cache"
PLATE_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")
IPAddress = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]


def default_workspace_root() -> Path:
    init_cwd = str(os.environ.get("INIT_CWD") or "").strip()
    return Path(init_cwd).expanduser().resolve() if init_cwd else Path.cwd().resolve()


DEFAULT_CONFIG_PATH = default_workspace_root() / "bambu-printers.json"


class BambuPrintError(RuntimeError):
    """Raised for expected user-facing workflow errors."""


@dataclass(frozen=True)
class GCodeInspection:
    path: str
    size_bytes: int
    md5: str


@dataclass(frozen=True)
class PlateInfo:
    index: int
    path: str
    size_bytes: int


@dataclass(frozen=True)
class ProjectInspection:
    path: str
    size_bytes: int
    md5: str
    plates: list[PlateInfo]


@dataclass(frozen=True)
class BamboxProfile:
    key: str
    machine: str
    nozzle_diameter: str
    enabled: bool
    reason: str
    allowed_filaments: set[str]


BAMBOX_PROFILES: dict[str, BamboxProfile] = {
    "p1s-0.4": BamboxProfile(
        key="p1s-0.4",
        machine="p1s",
        nozzle_diameter="0.4",
        enabled=True,
        reason="bambox 0.5.0 bundles and hardware-validates the Bambu Lab P1S 0.4 profile.",
        allowed_filaments={"PLA", "ASA", "PETG-CF"},
    ),
    "a1-mini-0.4": BamboxProfile(
        key="a1-mini-0.4",
        machine="a1-mini",
        nozzle_diameter="0.4",
        enabled=False,
        reason="no validated bambox A1 Mini profile is present.",
        allowed_filaments=set(),
    ),
    "a1-0.4": BamboxProfile(
        key="a1-0.4",
        machine="a1",
        nozzle_diameter="0.4",
        enabled=False,
        reason="no validated bambox A1 profile is present.",
        allowed_filaments=set(),
    ),
    "x1c-0.4": BamboxProfile(
        key="x1c-0.4",
        machine="x1c",
        nozzle_diameter="0.4",
        enabled=False,
        reason="no validated bambox X1C profile is present.",
        allowed_filaments=set(),
    ),
    "p1p-0.4": BamboxProfile(
        key="p1p-0.4",
        machine="p1p",
        nozzle_diameter="0.4",
        enabled=False,
        reason="no validated bambox P1P profile is present.",
        allowed_filaments=set(),
    ),
}


class ImplicitFTP_TLS(ftplib.FTP_TLS):
    """FTP_TLS variant for implicit FTPS servers such as Bambu printers."""

    def connect(self, host: str = "", port: int = 0, timeout: float | None = -999, source_address=None):
        if host:
            self.host = host
        if port:
            self.port = port
        if timeout != -999:
            self.timeout = timeout
        if source_address is not None:
            self.source_address = source_address
        raw_sock = socket.create_connection(
            (self.host, self.port),
            self.timeout,
            source_address=self.source_address,
        )
        self.af = raw_sock.family
        self.sock = self.context.wrap_socket(raw_sock, server_hostname=self.host)
        self.file = self.sock.makefile("r", encoding=self.encoding)
        self.welcome = self.getresp()
        return self.welcome

    def ntransfercmd(self, cmd: str, rest: Any = None):
        conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest)
        if self._prot_p:
            conn = self.context.wrap_socket(conn, server_hostname=self.host, session=self.sock.session)
        return conn, size


def config_path_from_args(args: argparse.Namespace) -> Path:
    return Path(getattr(args, "config", None) or DEFAULT_CONFIG_PATH).expanduser().resolve()


def load_config(path: Path) -> dict[str, Any]:
    if not path.is_file():
        return {"printers": {}}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise BambuPrintError(f"Printer config is not valid JSON: {path}: {exc}") from exc
    if not isinstance(data, dict):
        raise BambuPrintError(f"Printer config must be a JSON object: {path}")
    printers = data.setdefault("printers", {})
    if not isinstance(printers, dict):
        raise BambuPrintError("Printer config field printers must be an object.")
    return data


def save_config(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def sanitized_printer_entry(entry: dict[str, Any]) -> dict[str, Any]:
    return {key: value for key, value in entry.items() if key != "access_code"}


def printer_entry(data: dict[str, Any], printer_id: str) -> dict[str, Any]:
    printers = data.get("printers", {})
    entry = printers.get(printer_id)
    if not isinstance(entry, dict):
        known = ", ".join(sorted(printers)) or "<none>"
        raise BambuPrintError(f"Printer {printer_id!r} is not configured. Known printers: {known}")
    return entry


def apply_printer_config(args: argparse.Namespace) -> None:
    printer_id = getattr(args, "printer", "") or ""
    if not printer_id:
        return
    data = load_config(config_path_from_args(args))
    entry = printer_entry(data, printer_id)
    for attr, key in (
        ("host", "host"),
        ("access_code", "access_code"),
        ("serial", "serial"),
        ("model", "model"),
    ):
        if hasattr(args, attr) and not getattr(args, attr, "") and entry.get(key):
            setattr(args, attr, str(entry[key]))


def cache_printer_serial(args: argparse.Namespace, serial: str) -> None:
    printer_id = getattr(args, "printer", "") or ""
    if not printer_id:
        return
    path = config_path_from_args(args)
    data = load_config(path)
    entry = printer_entry(data, printer_id)
    if entry.get("serial") == serial:
        return
    entry["serial"] = serial
    entry["serial_source"] = "printer_tls_certificate_common_name"
    entry["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    save_config(path, data)


def md5_and_size(path: Path) -> tuple[str, int]:
    digest = hashlib.md5()
    size = 0
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            size += len(chunk)
            digest.update(chunk)
    return digest.hexdigest(), size


def inspect_gcode_file(path: Path) -> GCodeInspection:
    if not path.is_file():
        raise BambuPrintError(f"G-code input file does not exist: {path}")
    name = path.name.lower()
    if name.endswith(".gcode.3mf") or path.suffix.lower() == ".3mf":
        raise BambuPrintError("Expected plain .gcode, not a Bambu .gcode.3mf or .3mf archive.")
    if path.suffix.lower() != ".gcode":
        raise BambuPrintError(f"Expected a plain .gcode input, got: {path.name}")
    md5, size = md5_and_size(path)
    if size == 0:
        raise BambuPrintError(f"G-code input is empty: {path}")
    return GCodeInspection(path=str(path), size_bytes=size, md5=md5)


def inspect_sliced_3mf(path: Path) -> ProjectInspection:
    if not path.is_file():
        raise BambuPrintError(f"Packaged project file does not exist: {path}")

    md5, size = md5_and_size(path)
    try:
        archive = zipfile.ZipFile(path)
    except zipfile.BadZipFile as exc:
        raise BambuPrintError(f"Packaged project is not a readable 3MF/ZIP archive: {path}") from exc

    plates: list[PlateInfo] = []
    with archive:
        for info in archive.infolist():
            match = PLATE_RE.match(info.filename)
            if match:
                plates.append(
                    PlateInfo(
                        index=int(match.group(1)),
                        path=info.filename,
                        size_bytes=info.file_size,
                    )
                )

    if not plates:
        raise BambuPrintError("Packaged project has no Metadata/plate_N.gcode entries.")

    plates.sort(key=lambda item: item.index)
    return ProjectInspection(path=str(path), size_bytes=size, md5=md5, plates=plates)


def sanitize_remote_name(name: str) -> str:
    base = Path(name).name.strip()
    if not base:
        raise BambuPrintError("Remote filename cannot be empty.")
    return re.sub(r"[^A-Za-z0-9._+-]", "_", base)


def join_remote_path(remote_dir: str, remote_name: str) -> str:
    clean_dir = remote_dir.strip("/")
    clean_name = sanitize_remote_name(remote_name)
    return f"{clean_dir}/{clean_name}" if clean_dir else clean_name


def default_project_name(gcode_path: Path) -> str:
    if gcode_path.name.lower().endswith(".gcode"):
        return f"{gcode_path.name}.3mf"
    return f"{gcode_path.name}.gcode.3mf"


def project_subtask_name(remote_path: str) -> str:
    name = Path(remote_path).name
    for suffix in (".gcode.3mf", ".3mf", ".gcode"):
        if name.lower().endswith(suffix):
            name = name[: -len(suffix)]
    return name or "local_print"


def is_local_address(address: IPAddress) -> bool:
    return address.is_private or address.is_loopback or address.is_link_local


def resolve_host_addresses(host: str) -> list[IPAddress]:
    clean_host = host.strip("[]")
    try:
        return [ipaddress.ip_address(clean_host)]
    except ValueError:
        pass

    try:
        results = socket.getaddrinfo(clean_host, None, type=socket.SOCK_STREAM)
    except socket.gaierror as exc:
        raise BambuPrintError(f"Could not resolve printer host {host!r}: {exc}") from exc

    addresses = sorted({ipaddress.ip_address(item[4][0]) for item in results}, key=str)
    if not addresses:
        raise BambuPrintError(f"Could not resolve printer host {host!r}.")
    return addresses


def validate_local_host(host: str, allow_nonprivate: bool) -> None:
    if not host:
        raise BambuPrintError("Printer host/IP is required for network actions.")
    if allow_nonprivate:
        return

    addresses = resolve_host_addresses(host)
    nonprivate = [str(address) for address in addresses if not is_local_address(address)]
    if nonprivate:
        raise BambuPrintError(
            f"{host} resolves to non-private address(es): {', '.join(nonprivate)}. "
            "Refusing unless --allow-nonprivate-host is set."
        )


def common_name_from_decoded_cert(decoded: dict[str, Any]) -> str:
    for subject_item in decoded.get("subject", ()):
        for key, value in subject_item:
            if key == "commonName":
                return str(value)
    return ""


def decode_der_certificate(der_cert: bytes) -> dict[str, Any]:
    pem = ssl.DER_cert_to_PEM_cert(der_cert)
    temp_path = ""
    try:
        with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".pem", delete=False) as handle:
            temp_path = handle.name
            handle.write(pem)
        return ssl._ssl._test_decode_cert(temp_path)  # type: ignore[attr-defined]
    finally:
        if temp_path:
            try:
                Path(temp_path).unlink()
            except FileNotFoundError:
                pass


def discover_printer_serial(
    *,
    host: str,
    port: int = DEFAULT_MQTT_PORT,
    timeout: float = 20.0,
    tls_verify: bool = False,
    allow_nonprivate_host: bool = False,
) -> str:
    validate_local_host(host, allow_nonprivate_host)
    context = ssl.create_default_context()
    if not tls_verify:
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
    with socket.create_connection((host, port), timeout=timeout) as raw_sock:
        with context.wrap_socket(raw_sock, server_hostname=host) as sock:
            der_cert = sock.getpeercert(binary_form=True)
    if not der_cert:
        raise BambuPrintError("Printer did not provide a TLS certificate for serial discovery.")
    serial = common_name_from_decoded_cert(decode_der_certificate(der_cert)).strip()
    if not serial:
        raise BambuPrintError("Could not find printer serial in the local TLS certificate common name.")
    return serial


def ensure_printer_serial(args: argparse.Namespace) -> str:
    if getattr(args, "serial", ""):
        return args.serial
    if not getattr(args, "host", ""):
        return ""
    args.serial = discover_printer_serial(
        host=args.host,
        port=args.mqtt_port,
        timeout=args.timeout,
        tls_verify=args.tls_verify,
        allow_nonprivate_host=args.allow_nonprivate_host,
    )
    cache_printer_serial(args, args.serial)
    return args.serial


def selected_plate(inspection: ProjectInspection, requested: int | None) -> PlateInfo:
    if requested is None:
        return inspection.plates[0]
    for plate in inspection.plates:
        if plate.index == requested:
            return plate
    available = ", ".join(str(plate.index) for plate in inspection.plates)
    raise BambuPrintError(f"Plate {requested} not found. Available plates: {available}")


def sequence_id(value: str | None) -> str:
    if value:
        return value
    return str(int(time.time() * 1000))


def build_gcode_file_payload(*, remote_path: str, args: argparse.Namespace) -> dict[str, Any]:
    return {
        "print": {
            "sequence_id": sequence_id(args.sequence_id),
            "command": "gcode_file",
            "param": args.gcode_param or remote_path,
        }
    }


def build_clean_print_error_payload(args: argparse.Namespace) -> dict[str, Any]:
    return {
        "print": {
            "sequence_id": sequence_id(args.sequence_id),
            "command": "clean_print_error",
        }
    }


def build_print_control_payload(args: argparse.Namespace) -> dict[str, Any]:
    command = "stop" if args.action == "cancel" else args.action
    payload = {
        "print": {
            "sequence_id": sequence_id(args.sequence_id),
            "command": command,
        }
    }
    if command == "stop":
        payload["print"]["param"] = ""
    return payload


def build_project_file_payload(
    *,
    plate: PlateInfo,
    remote_path: str,
    remote_url: str,
    md5: str | None,
    args: argparse.Namespace,
) -> dict[str, Any]:
    return {
        "print": {
            "sequence_id": sequence_id(args.sequence_id),
            "command": "project_file",
            "param": plate.path,
            "project_id": "0",
            "profile_id": "0",
            "task_id": "0",
            "subtask_id": "0",
            "subtask_name": project_subtask_name(remote_path),
            "url": remote_url,
            "md5": md5.upper() if md5 else None,
            "timelapse": False,
            "bed_type": "auto",
            "bed_levelling": True,
            "flow_cali": True,
            "vibration_cali": False,
            "layer_inspect": True,
            "use_ams": False,
            "ams_mapping": "",
        }
    }


def bambox_profile(profile_key: str) -> BamboxProfile:
    try:
        profile = BAMBOX_PROFILES[profile_key]
    except KeyError as exc:
        known = ", ".join(sorted(BAMBOX_PROFILES))
        raise BambuPrintError(f"Unknown bambox profile {profile_key!r}. Known profiles: {known}") from exc
    if not profile.enabled:
        raise BambuPrintError(f"{profile.key} is not enabled because {profile.reason}")
    return profile


def bambox_profile_payload(profile: BamboxProfile) -> dict[str, Any]:
    return {
        "key": profile.key,
        "machine": profile.machine,
        "nozzle_diameter": profile.nozzle_diameter,
        "enabled": profile.enabled,
        "reason": profile.reason,
        "allowed_filaments": sorted(profile.allowed_filaments),
    }


def bambox_filament_type(spec: str) -> str:
    parts = spec.split(":")
    if len(parts) >= 2 and parts[0].isdigit():
        value = parts[1]
    else:
        value = parts[0]
    return value.strip().upper()


def validate_bambox_filaments(profile: BamboxProfile, filaments: list[str] | None) -> list[str]:
    if not filaments:
        raise BambuPrintError("At least one --filament is required for bambox packaging.")
    for spec in filaments:
        filament_type = bambox_filament_type(spec)
        if filament_type not in profile.allowed_filaments:
            allowed = ", ".join(sorted(profile.allowed_filaments))
            raise BambuPrintError(
                f"Filament {filament_type!r} is not enabled for {profile.key}. Allowed filaments: {allowed}"
            )
    return filaments


def resolve_bambox_bin(requested: str | None, *, require_exists: bool) -> str:
    candidate = requested or os.environ.get("BAMBOX_BIN") or "bambox"
    if not require_exists:
        return candidate
    resolved = shutil.which(candidate) if Path(candidate).name == candidate else candidate
    if not resolved or not Path(resolved).exists():
        raise BambuPrintError("bambox CLI was not found. Install it separately or set BAMBOX_BIN.")
    return resolved


def build_bambox_pack_command(
    *,
    gcode_path: Path,
    output_path: Path,
    profile_key: str,
    filaments: list[str] | None,
    bambox_bin: str | None = None,
    require_bambox: bool = False,
) -> list[str]:
    profile = bambox_profile(profile_key)
    safe_filaments = validate_bambox_filaments(profile, filaments)
    command = [
        resolve_bambox_bin(bambox_bin, require_exists=require_bambox),
        "pack",
        str(gcode_path),
        "-o",
        str(output_path),
        "-m",
        profile.machine,
        "--nozzle-diameter",
        profile.nozzle_diameter,
    ]
    for filament in safe_filaments:
        command.extend(["-f", filament])
    return command


def build_bambox_validate_command(*, project_path: Path, bambox_bin: str | None = None, require_bambox: bool = False) -> list[str]:
    return [
        resolve_bambox_bin(bambox_bin, require_exists=require_bambox),
        "validate",
        str(project_path),
        "--json",
        "--strict",
    ]


def run_checked_command(command: list[str], label: str) -> dict[str, Any]:
    result = subprocess.run(command, check=False, capture_output=True, text=True)
    payload: dict[str, Any] = {"command": command, "returncode": result.returncode}
    if result.stdout:
        payload["stdout_tail"] = tail_text(result.stdout)
    if result.stderr:
        payload["stderr_tail"] = tail_text(result.stderr)
    if result.returncode != 0:
        raise BambuPrintError(f"{label} failed: {json.dumps(payload, indent=2)}")
    return payload


def package_with_bambox(args: argparse.Namespace, output_path: Path) -> dict[str, Any]:
    gcode_path = Path(args.gcode).expanduser().resolve()
    inspect_gcode_file(gcode_path)
    pack_command = build_bambox_pack_command(
        gcode_path=gcode_path,
        output_path=output_path,
        profile_key=args.bambox_profile,
        filaments=args.filament,
        bambox_bin=args.bambox_bin,
        require_bambox=True,
    )
    validate_command = build_bambox_validate_command(
        project_path=output_path,
        bambox_bin=args.bambox_bin,
        require_bambox=True,
    )
    pack_result = run_checked_command(pack_command, "bambox pack")
    if not output_path.is_file():
        raise BambuPrintError(f"bambox pack did not create expected output: {output_path}")
    project_inspection = inspect_sliced_3mf(output_path)
    validate_result = run_checked_command(validate_command, "bambox validate")
    return {
        "pack": pack_result,
        "validate": validate_result,
        "project": asdict(project_inspection),
    }


def package_with_template_project(args: argparse.Namespace, output_path: Path) -> dict[str, Any]:
    gcode_path = Path(args.gcode).expanduser().resolve()
    inspection = inspect_gcode_file(gcode_path)
    if not args.template_project:
        raise BambuPrintError("--template-project is required for template-project packaging.")
    template_path = Path(args.template_project).expanduser().resolve()
    template_inspection = inspect_sliced_3mf(template_path)
    plate = selected_plate(template_inspection, args.plate)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    gcode_bytes = gcode_path.read_bytes()
    gcode_md5 = hashlib.md5(gcode_bytes).hexdigest().upper()
    md5_path = f"{plate.path}.md5"
    found_plate = False
    found_md5 = False

    with zipfile.ZipFile(template_path, "r") as source, zipfile.ZipFile(
        output_path, "w", compression=zipfile.ZIP_DEFLATED
    ) as target:
        for info in source.infolist():
            data = source.read(info.filename)
            if info.filename == plate.path:
                data = gcode_bytes
                found_plate = True
            elif info.filename == md5_path:
                data = gcode_md5.encode("ascii")
                found_md5 = True
            target.writestr(info, data)
        if not found_md5:
            target.writestr(md5_path, gcode_md5)

    if not found_plate:
        raise BambuPrintError(f"Template project plate G-code entry not found: {plate.path}")

    project_inspection = inspect_sliced_3mf(output_path)
    return {
        "pack": {
            "template": str(template_path),
            "output": str(output_path),
            "replaced_plate": plate.path,
            "plate_gcode_md5": gcode_md5,
            "input": asdict(inspection),
        },
        "project": asdict(project_inspection),
    }


def build_package_plan(args: argparse.Namespace) -> dict[str, Any]:
    gcode_path = Path(args.gcode).expanduser().resolve()
    inspection = inspect_gcode_file(gcode_path)
    output_path = Path(args.output).expanduser().resolve()
    pack_command = build_bambox_pack_command(
        gcode_path=gcode_path,
        output_path=output_path,
        profile_key=args.bambox_profile,
        filaments=args.filament,
        bambox_bin=args.bambox_bin,
        require_bambox=False,
    )
    validate_command = build_bambox_validate_command(
        project_path=output_path,
        bambox_bin=args.bambox_bin,
        require_bambox=False,
    )
    return {
        "dry_run": not args.execute,
        "action": "package",
        "input": asdict(inspection),
        "output": str(output_path),
        "bambox": {
            "profile": bambox_profile_payload(bambox_profile(args.bambox_profile)),
            "filaments": args.filament,
            "pack_command": pack_command,
            "validate_command": validate_command,
        },
        "notes": [
            "bambox is optional and must be installed separately.",
            "Only enabled bambox profiles can be packaged.",
            "A1 Mini is intentionally disabled until a validated bambox profile is available.",
        ],
    }


def build_plain_send_plan(args: argparse.Namespace) -> dict[str, Any]:
    gcode_path = Path(args.gcode).expanduser().resolve()
    inspection = inspect_gcode_file(gcode_path)
    remote_name = sanitize_remote_name(args.remote_name or gcode_path.name)
    remote_path = join_remote_path(args.remote_dir or DEFAULT_REMOTE_DIR, remote_name)
    payload = build_gcode_file_payload(remote_path=remote_path, args=args)
    return build_common_send_plan(
        args=args,
        input_payload={**asdict(inspection), "kind": "plain_gcode"},
        local_path=str(gcode_path),
        remote_path=remote_path,
        remote_url=None,
        payload=payload,
    )


def build_bambox_project_send_plan(args: argparse.Namespace, project_path: Path | None = None) -> dict[str, Any]:
    gcode_path = Path(args.gcode).expanduser().resolve()
    inspection = inspect_gcode_file(gcode_path)
    remote_name = sanitize_remote_name(args.remote_name or default_project_name(gcode_path))
    remote_path = join_remote_path(args.remote_dir or "", remote_name)
    remote_url = f"ftp:///{remote_path}"
    output_path = project_path or Path(tempfile.gettempdir()) / default_project_name(gcode_path)
    pack_command = build_bambox_pack_command(
        gcode_path=gcode_path,
        output_path=output_path,
        profile_key=args.bambox_profile,
        filaments=args.filament,
        bambox_bin=args.bambox_bin,
        require_bambox=False,
    )
    validate_command = build_bambox_validate_command(
        project_path=output_path,
        bambox_bin=args.bambox_bin,
        require_bambox=False,
    )

    project: dict[str, Any] | None = None
    payload: dict[str, Any]
    if project_path is not None:
        project_inspection = inspect_sliced_3mf(project_path)
        plate = selected_plate(project_inspection, args.plate)
        project = {**asdict(project_inspection), "selected_plate": asdict(plate)}
        payload = build_project_file_payload(
            plate=plate,
            remote_path=remote_path,
            remote_url=remote_url,
            md5=project_inspection.md5,
            args=args,
        )
    else:
        payload = build_project_file_payload(
            plate=PlateInfo(index=1, path="Metadata/plate_1.gcode", size_bytes=0),
            remote_path=remote_path,
            remote_url=remote_url,
            md5=None,
            args=args,
        )

    return build_common_send_plan(
        args=args,
        input_payload={
            **asdict(inspection),
            "kind": "plain_gcode",
            "bambox_project": project,
            "bambox": {
                "profile": bambox_profile_payload(bambox_profile(args.bambox_profile)),
                "filaments": args.filament,
                "pack_command": pack_command,
                "validate_command": validate_command,
            },
        },
        local_path=str(project_path) if project_path is not None else None,
        remote_path=remote_path,
        remote_url=remote_url,
        payload=payload,
    )


def build_template_project_send_plan(args: argparse.Namespace, project_path: Path | None = None) -> dict[str, Any]:
    gcode_path = Path(args.gcode).expanduser().resolve()
    inspection = inspect_gcode_file(gcode_path)
    template_path = Path(args.template_project).expanduser().resolve() if args.template_project else None
    if template_path is None:
        raise BambuPrintError("--template-project is required for --handoff template-project.")
    inspect_sliced_3mf(template_path)
    remote_name = sanitize_remote_name(args.remote_name or default_project_name(gcode_path))
    remote_path = join_remote_path(args.remote_dir or "", remote_name)
    remote_url = f"ftp:///{remote_path}"
    output_path = project_path or Path(tempfile.gettempdir()) / default_project_name(gcode_path)

    project: dict[str, Any] | None = None
    if project_path is not None:
        project_inspection = inspect_sliced_3mf(project_path)
        plate = selected_plate(project_inspection, args.plate)
        project = {**asdict(project_inspection), "selected_plate": asdict(plate)}
        payload = build_project_file_payload(
            plate=plate,
            remote_path=remote_path,
            remote_url=remote_url,
            md5=project_inspection.md5,
            args=args,
        )
    else:
        payload = build_project_file_payload(
            plate=PlateInfo(index=1, path="Metadata/plate_1.gcode", size_bytes=0),
            remote_path=remote_path,
            remote_url=remote_url,
            md5=None,
            args=args,
        )

    return build_common_send_plan(
        args=args,
        input_payload={
            **asdict(inspection),
            "kind": "plain_gcode",
            "template_project": {
                "template": str(template_path),
                "output": str(output_path),
                "packaged_project": project,
                "note": "Experimental A1/A1 Mini path: replaces Metadata/plate_1.gcode in a same-printer template project.",
            },
        },
        local_path=str(project_path) if project_path is not None else None,
        remote_path=remote_path,
        remote_url=remote_url,
        payload=payload,
    )


def build_common_send_plan(
    *,
    args: argparse.Namespace,
    input_payload: dict[str, Any],
    local_path: str | None,
    remote_path: str,
    remote_url: str | None,
    payload: dict[str, Any],
) -> dict[str, Any]:
    serial = getattr(args, "serial", "") or ""
    topic = f"device/{serial}/request" if serial else None
    will_upload = args.action in {"upload", "upload-start"}
    will_publish = args.action in {"start", "upload-start"}

    ftps: dict[str, Any] = {
        "host": args.host,
        "port": args.ftps_port,
        "username": BAMBU_USERNAME,
        "remote_path": remote_path,
        "will_upload": will_upload,
    }
    if remote_url:
        ftps["url"] = remote_url

    return {
        "dry_run": not args.execute,
        "action": args.action,
        "handoff": args.handoff,
        "input": input_payload,
        "local_upload_path": local_path,
        "ftps": ftps,
        "mqtt": {
            "host": args.host,
            "port": args.mqtt_port,
            "username": BAMBU_USERNAME,
            "topic": topic,
            "payload": payload,
            "will_publish": will_publish,
        },
        "safety": [
            "Dry-run is the default; network actions require --execute.",
            "Starting a print requires --confirm-start-print.",
            "MQTT start publishes a request only; watch the printer UI/state for acceptance and progress.",
            "Verify the build plate is clear, filament/path are correct, and the printer is physically safe before execution.",
        ],
    }


def build_clear_error_plan(args: argparse.Namespace) -> dict[str, Any]:
    serial = getattr(args, "serial", "") or ""
    topic = f"device/{serial}/request" if serial else None
    return {
        "dry_run": not args.execute,
        "action": "clear-error",
        "printer": args.printer or None,
        "host": args.host,
        "mqtt": {
            "host": args.host,
            "port": args.mqtt_port,
            "username": BAMBU_USERNAME,
            "topic": topic,
            "payload": build_clean_print_error_payload(args),
            "will_publish": bool(args.execute),
        },
        "notes": [
            "This publishes clean_print_error only; it does not upload files or start a print.",
            "Use after resolving the underlying cause of a stale printer error, then read status before retrying.",
        ],
    }


def build_print_control_plan(args: argparse.Namespace) -> dict[str, Any]:
    serial = getattr(args, "serial", "") or ""
    topic = f"device/{serial}/request" if serial else None
    return {
        "dry_run": not args.execute,
        "action": args.action,
        "printer": args.printer or None,
        "host": args.host,
        "mqtt": {
            "host": args.host,
            "port": args.mqtt_port,
            "username": BAMBU_USERNAME,
            "topic": topic,
            "payload": build_print_control_payload(args),
            "will_publish": bool(args.execute),
        },
        "notes": [
            "This publishes a print-control request only; it does not upload files or start a print.",
            "Read printer status after publishing to confirm the state changed as intended.",
        ],
    }


def build_send_plan(args: argparse.Namespace, project_path: Path | None = None) -> dict[str, Any]:
    apply_printer_config(args)
    if args.handoff == "plain":
        return build_plain_send_plan(args)
    if args.handoff == "bambox-project":
        return build_bambox_project_send_plan(args, project_path=project_path)
    if args.handoff == "template-project":
        return build_template_project_send_plan(args, project_path=project_path)
    raise BambuPrintError(f"Unsupported handoff: {args.handoff}")


def upload_ftps(args: argparse.Namespace, local_path: Path, remote_path: str) -> None:
    access_code = access_code_from_args(args)
    validate_local_host(args.host, args.allow_nonprivate_host)
    context = ssl.create_default_context()
    if not args.tls_verify:
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
    ftp = ImplicitFTP_TLS(context=context, timeout=args.timeout)
    ftp.connect(args.host, args.ftps_port)
    try:
        ftp.login(BAMBU_USERNAME, access_code)
        ftp.prot_p()
        remote_dir = remote_path.rsplit("/", 1)[0] if "/" in remote_path else ""
        remote_name = Path(remote_path).name
        if remote_dir:
            try:
                ftp.cwd(remote_dir)
            except ftplib.error_perm:
                ftp.mkd(remote_dir)
                ftp.cwd(remote_dir)
        with local_path.open("rb") as handle:
            storbinary_with_bambu_timeout_tolerance(ftp, f"STOR {remote_name}", handle)
    finally:
        try:
            ftp.quit()
        except Exception:
            ftp.close()


def storbinary_with_bambu_timeout_tolerance(ftp: ftplib.FTP_TLS, command: str, handle: Any) -> None:
    try:
        ftp.storbinary(command, handle)
    except socket.timeout as exc:
        try:
            ftp.voidresp()
        except Exception as confirm_exc:
            raise BambuPrintError(
                "FTPS upload timed out before the printer confirmed completion. "
                "The file may be partially uploaded; retry upload-only after checking printer storage."
            ) from confirm_exc
        # Some Bambu FTPS firmware completes STOR but does not close the TLS data
        # channel the way Python's ftplib expects. A 226 control response is enough.


def encode_mqtt_string(value: str) -> bytes:
    encoded = value.encode("utf-8")
    if len(encoded) > 65535:
        raise BambuPrintError("MQTT string is too long.")
    return len(encoded).to_bytes(2, "big") + encoded


def encode_remaining_length(length: int) -> bytes:
    out = bytearray()
    while True:
        byte = length % 128
        length //= 128
        if length:
            byte |= 0x80
        out.append(byte)
        if not length:
            return bytes(out)


def read_exact(sock: ssl.SSLSocket, size: int) -> bytes:
    chunks = bytearray()
    while len(chunks) < size:
        chunk = sock.recv(size - len(chunks))
        if not chunk:
            raise BambuPrintError("MQTT connection closed unexpectedly.")
        chunks.extend(chunk)
    return bytes(chunks)


def read_mqtt_packet(sock: ssl.SSLSocket) -> tuple[int, bytes]:
    header = read_exact(sock, 1)[0]
    multiplier = 1
    remaining = 0
    while True:
        digit = read_exact(sock, 1)[0]
        remaining += (digit & 127) * multiplier
        if not digit & 128:
            break
        multiplier *= 128
        if multiplier > 128 * 128 * 128:
            raise BambuPrintError("Malformed MQTT remaining length.")
    return header, read_exact(sock, remaining)


def mqtt_connect_packet(client_id: str, username: str, password: str, keepalive: int = 30) -> bytes:
    variable = encode_mqtt_string("MQTT") + bytes([4, 0xC2]) + keepalive.to_bytes(2, "big")
    payload = encode_mqtt_string(client_id) + encode_mqtt_string(username) + encode_mqtt_string(password)
    remaining = variable + payload
    return bytes([0x10]) + encode_remaining_length(len(remaining)) + remaining


def mqtt_publish_packet(topic: str, payload: bytes, *, qos: int = 0, packet_id: int = 1) -> bytes:
    if qos not in {0, 1}:
        raise BambuPrintError("Only MQTT QoS 0 and QoS 1 are supported.")
    variable = encode_mqtt_string(topic)
    if qos:
        variable += packet_id.to_bytes(2, "big")
    remaining = variable + payload
    return bytes([0x30 | (qos << 1)]) + encode_remaining_length(len(remaining)) + remaining


def mqtt_subscribe_packet(packet_id: int, topic: str) -> bytes:
    variable = packet_id.to_bytes(2, "big")
    payload = encode_mqtt_string(topic) + bytes([0])
    remaining = variable + payload
    return bytes([0x82]) + encode_remaining_length(len(remaining)) + remaining


def parse_mqtt_publish(header: int, body: bytes) -> tuple[str, bytes]:
    if header & 0xF0 != 0x30:
        raise BambuPrintError(f"Expected MQTT PUBLISH packet, got header=0x{header:02x}.")
    if len(body) < 2:
        raise BambuPrintError("Malformed MQTT PUBLISH packet.")
    topic_length = int.from_bytes(body[:2], "big")
    topic_start = 2
    topic_end = topic_start + topic_length
    topic = body[topic_start:topic_end].decode("utf-8", errors="replace")
    payload_start = topic_end
    qos = (header & 0x06) >> 1
    if qos:
        payload_start += 2
    return topic, body[payload_start:]


def format_hms_code(attr: Any, code: Any) -> str:
    try:
        attr_int = int(attr)
        code_int = int(code)
    except (TypeError, ValueError) as exc:
        raise BambuPrintError("HMS attr/code values must be integers.") from exc
    return f"{(attr_int >> 16) & 0xFFFF:04X}-{attr_int & 0xFFFF:04X}-{(code_int >> 16) & 0xFFFF:04X}-{code_int & 0xFFFF:04X}"


def annotate_hms_codes(message: dict[str, Any]) -> None:
    payload = message.get("json")
    if not isinstance(payload, dict):
        return
    print_payload = payload.get("print")
    if not isinstance(print_payload, dict):
        return
    hms_items = print_payload.get("hms")
    if not isinstance(hms_items, list):
        return
    decoded: list[dict[str, Any]] = []
    for item in hms_items:
        if not isinstance(item, dict) or "attr" not in item or "code" not in item:
            continue
        decoded.append({**item, "hms_code": format_hms_code(item["attr"], item["code"])})
    if decoded:
        message["hms"] = decoded


def mqtt_puback_packet(packet_id: int) -> bytes:
    return bytes([0x40, 0x02]) + packet_id.to_bytes(2, "big")


def read_mqtt_reports_until(
    sock: ssl.SSLSocket,
    *,
    deadline: float,
    timeout: float,
    expected_puback_packet_id: int | None = None,
    stop_after_puback: bool = False,
) -> dict[str, Any]:
    messages: list[dict[str, Any]] = []
    puback_received = expected_puback_packet_id is None
    while time.monotonic() < deadline:
        sock.settimeout(max(0.1, min(timeout, deadline - time.monotonic())))
        try:
            header, body = read_mqtt_packet(sock)
        except socket.timeout:
            break
        if header == 0x40:
            packet_id = int.from_bytes(body[:2], "big") if len(body) >= 2 else None
            if packet_id == expected_puback_packet_id:
                puback_received = True
                if stop_after_puback:
                    break
            continue
        if header & 0xF0 != 0x30:
            continue
        message_topic, report_payload = parse_mqtt_publish(header, body)
        text = report_payload.decode("utf-8", errors="replace")
        item: dict[str, Any] = {"topic": message_topic, "payload": text}
        try:
            item["json"] = json.loads(text)
        except json.JSONDecodeError:
            pass
        annotate_hms_codes(item)
        messages.append(item)
        qos = (header & 0x06) >> 1
        if qos == 1:
            topic_length = int.from_bytes(body[:2], "big")
            publish_packet_id_start = 2 + topic_length
            packet_id = int.from_bytes(body[publish_packet_id_start : publish_packet_id_start + 2], "big")
            sock.sendall(mqtt_puback_packet(packet_id))
    return {"puback_received": puback_received, "messages": messages}


def publish_mqtt(
    args: argparse.Namespace,
    topic: str,
    payload: dict[str, Any],
    *,
    report_topic: str | None = None,
) -> dict[str, Any]:
    access_code = access_code_from_args(args)
    validate_local_host(args.host, args.allow_nonprivate_host)
    context = ssl.create_default_context()
    if not args.tls_verify:
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
    payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
    client_id = args.client_id or args.serial or f"codex-bambu-{int(time.time())}"
    raw_mqtt_qos = getattr(args, "mqtt_qos", 0)
    if not isinstance(raw_mqtt_qos, (int, float, str)):
        raw_mqtt_qos = 0
    mqtt_qos = int(raw_mqtt_qos)
    publish_packet_id = 2
    raw_wait_after_publish = getattr(args, "wait_after_publish", 0.0)
    if not isinstance(raw_wait_after_publish, (int, float, str)):
        raw_wait_after_publish = 0.0
    wait_after_publish = float(raw_wait_after_publish or 0.0)
    diagnostics: dict[str, Any] = {
        "qos": mqtt_qos,
        "puback_required": mqtt_qos == 1,
        "puback_received": mqtt_qos == 0,
        "report_topic": report_topic if wait_after_publish and report_topic else None,
        "messages": [],
    }
    with socket.create_connection((args.host, args.mqtt_port), timeout=args.timeout) as raw_sock:
        with context.wrap_socket(raw_sock, server_hostname=args.host) as sock:
            sock.sendall(mqtt_connect_packet(client_id, BAMBU_USERNAME, access_code))
            header, body = read_mqtt_packet(sock)
            if header != 0x20 or len(body) != 2 or body[1] != 0:
                raise BambuPrintError(f"MQTT CONNACK failed: header=0x{header:02x} body={body.hex()}")
            if wait_after_publish and report_topic:
                sock.sendall(mqtt_subscribe_packet(1, report_topic))
                header, body = read_mqtt_packet(sock)
                if header != 0x90 or len(body) < 3 or body[-1] == 0x80:
                    raise BambuPrintError(f"MQTT SUBACK failed: header=0x{header:02x} body={body.hex()}")
            sock.sendall(mqtt_publish_packet(topic, payload_bytes, qos=mqtt_qos, packet_id=publish_packet_id))
            if mqtt_qos == 1 or wait_after_publish:
                wait_seconds = wait_after_publish if wait_after_publish else args.timeout
                observed = read_mqtt_reports_until(
                    sock,
                    deadline=time.monotonic() + wait_seconds,
                    timeout=args.timeout,
                    expected_puback_packet_id=publish_packet_id if mqtt_qos == 1 else None,
                    stop_after_puback=mqtt_qos == 1 and not wait_after_publish,
                )
                diagnostics.update(observed)
            sock.sendall(bytes([0xE0, 0x00]))
    return diagnostics


def subscribe_mqtt_reports(
    args: argparse.Namespace,
    topic: str,
    wait_seconds: float,
    *,
    push_all_topic: str | None = None,
    max_messages: int = 1,
) -> list[dict[str, Any]]:
    access_code = access_code_from_args(args)
    validate_local_host(args.host, args.allow_nonprivate_host)
    context = ssl.create_default_context()
    if not args.tls_verify:
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
    client_id = args.client_id or args.serial or f"codex-bambu-status-{int(time.time())}"
    messages: list[dict[str, Any]] = []
    deadline = time.monotonic() + wait_seconds
    with socket.create_connection((args.host, args.mqtt_port), timeout=args.timeout) as raw_sock:
        with context.wrap_socket(raw_sock, server_hostname=args.host) as sock:
            sock.sendall(mqtt_connect_packet(client_id, BAMBU_USERNAME, access_code))
            header, body = read_mqtt_packet(sock)
            if header != 0x20 or len(body) != 2 or body[1] != 0:
                raise BambuPrintError(f"MQTT CONNACK failed: header=0x{header:02x} body={body.hex()}")
            sock.sendall(mqtt_subscribe_packet(1, topic))
            header, body = read_mqtt_packet(sock)
            if header != 0x90 or len(body) < 3 or body[-1] == 0x80:
                raise BambuPrintError(f"MQTT SUBACK failed: header=0x{header:02x} body={body.hex()}")
            if push_all_topic:
                payload = {
                    "pushing": {
                        "sequence_id": sequence_id(args.sequence_id),
                        "command": "pushall",
                        "version": 1,
                        "push_target": 1,
                    }
                }
                sock.sendall(mqtt_publish_packet(push_all_topic, json.dumps(payload, separators=(",", ":")).encode()))
            while time.monotonic() < deadline:
                sock.settimeout(max(0.1, min(args.timeout, deadline - time.monotonic())))
                try:
                    header, body = read_mqtt_packet(sock)
                except socket.timeout:
                    break
                if header & 0xF0 != 0x30:
                    continue
                message_topic, payload = parse_mqtt_publish(header, body)
                text = payload.decode("utf-8", errors="replace")
                item: dict[str, Any] = {"topic": message_topic, "payload": text}
                try:
                    item["json"] = json.loads(text)
                except json.JSONDecodeError:
                    pass
                annotate_hms_codes(item)
                messages.append(item)
                if len(messages) >= max_messages:
                    break
                payload_json = item.get("json")
                if (
                    push_all_topic
                    and isinstance(payload_json, dict)
                    and isinstance(payload_json.get("print"), dict)
                    and payload_json["print"].get("command") == "push_status"
                    and payload_json["print"].get("msg") == 0
                ):
                    break
            sock.sendall(bytes([0xE0, 0x00]))
    return messages


def access_code_from_args(args: argparse.Namespace) -> str:
    access_code = args.access_code or ""
    if not access_code:
        raise BambuPrintError("Access code is required for --execute. Configure --printer with config set or pass --access-code.")
    if set(access_code) == {"0"}:
        raise BambuPrintError("Access code is all zeros; refresh the printer access code before execution.")
    return access_code


def tail_text(value: str, *, max_lines: int = 80) -> str:
    lines = value.splitlines()
    if len(lines) <= max_lines:
        return value
    return "\n".join(lines[-max_lines:])


def package_main(args: argparse.Namespace) -> int:
    plan = build_package_plan(args)
    if not args.execute:
        print(json.dumps(plan, indent=2))
        return 0

    output_path = Path(args.output).expanduser().resolve()
    result = package_with_bambox(args, output_path)
    plan["dry_run"] = False
    plan["executed"] = ["bambox_pack", "bambox_validate"]
    plan["result"] = result
    print(json.dumps(plan, indent=2))
    return 0


def config_set_main(args: argparse.Namespace) -> int:
    path = config_path_from_args(args)
    data = load_config(path)
    printers = data.setdefault("printers", {})
    entry = printers.get(args.printer)
    if not isinstance(entry, dict):
        entry = {}
        printers[args.printer] = entry
    if args.host:
        entry["host"] = args.host
    if args.access_code:
        entry["access_code"] = args.access_code
    if args.model:
        entry["model"] = args.model
    if args.notes:
        entry["notes"] = args.notes
    serial = args.serial or ""
    if args.fetch_serial:
        host = args.host or str(entry.get("host") or "")
        if not host:
            raise BambuPrintError("Cannot fetch serial without --host or an existing host in the printer config.")
        serial = discover_printer_serial(
            host=host,
            port=args.mqtt_port,
            timeout=args.timeout,
            tls_verify=args.tls_verify,
            allow_nonprivate_host=args.allow_nonprivate_host,
        )
        entry["serial_source"] = "printer_tls_certificate_common_name"
    if serial:
        entry["serial"] = serial
    entry["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    save_config(path, data)
    print(json.dumps({"ok": True, "config": str(path), "printer": args.printer, "entry": sanitized_printer_entry(entry)}, indent=2))
    return 0


def config_list_main(args: argparse.Namespace) -> int:
    path = config_path_from_args(args)
    data = load_config(path)
    printers = data.get("printers", {})
    payload = {
        "config": str(path),
        "printers": {printer_id: sanitized_printer_entry(entry) for printer_id, entry in sorted(printers.items())},
    }
    print(json.dumps(payload, indent=2))
    return 0


def config_show_main(args: argparse.Namespace) -> int:
    path = config_path_from_args(args)
    data = load_config(path)
    entry = printer_entry(data, args.printer)
    print(json.dumps({"config": str(path), "printer": args.printer, "entry": sanitized_printer_entry(entry)}, indent=2))
    return 0


def serial_main(args: argparse.Namespace) -> int:
    apply_printer_config(args)
    serial = discover_printer_serial(
        host=args.host,
        port=args.mqtt_port,
        timeout=args.timeout,
        tls_verify=args.tls_verify,
        allow_nonprivate_host=args.allow_nonprivate_host,
    )
    args.serial = serial
    cache_printer_serial(args, serial)
    payload = {
        "ok": True,
        "printer": args.printer or None,
        "host": args.host,
        "config": str(config_path_from_args(args)) if args.printer else None,
        "port": args.mqtt_port,
        "serial": serial,
        "source": "printer_tls_certificate_common_name",
    }
    if args.json:
        print(json.dumps(payload, indent=2))
    else:
        print(serial)
    return 0


def status_main(args: argparse.Namespace) -> int:
    apply_printer_config(args)
    serial = ensure_printer_serial(args)
    if not serial:
        raise BambuPrintError("Printer serial is required for status; configure --printer or pass --serial.")
    topic = args.topic or f"device/{serial}/report"
    push_all_topic = f"device/{serial}/request" if args.push_all else None
    messages = subscribe_mqtt_reports(
        args,
        topic,
        args.wait_seconds,
        push_all_topic=push_all_topic,
        max_messages=args.max_messages,
    )
    payload = {
        "ok": bool(messages),
        "printer": args.printer or None,
        "host": args.host,
        "topic": topic,
        "push_all_requested": bool(args.push_all),
        "messages": messages,
    }
    if not messages:
        payload["warning"] = f"No report message received within {args.wait_seconds:g} seconds."
    print(json.dumps(payload, indent=2))
    return 0 if messages else 1


def clear_error_main(args: argparse.Namespace) -> int:
    apply_printer_config(args)
    if args.execute and not ensure_printer_serial(args):
        raise BambuPrintError("Printer serial is required to publish clean_print_error.")
    plan = build_clear_error_plan(args)
    if not args.execute:
        print(json.dumps(plan, indent=2))
        return 0
    publish_result = publish_mqtt(
        args,
        plan["mqtt"]["topic"],
        plan["mqtt"]["payload"],
        report_topic=f"device/{args.serial}/report" if getattr(args, "serial", "") else None,
    )
    plan["dry_run"] = False
    plan["executed"] = ["publish_clean_print_error"]
    plan["mqtt_publish_result"] = publish_result
    print(json.dumps(plan, indent=2))
    return 0


def print_control_main(args: argparse.Namespace) -> int:
    apply_printer_config(args)
    if args.execute and args.action == "cancel" and not args.confirm_cancel_print:
        raise BambuPrintError("Canceling a print requires --confirm-cancel-print.")
    if args.execute and not ensure_printer_serial(args):
        raise BambuPrintError("Printer serial is required to publish a print-control command.")
    plan = build_print_control_plan(args)
    if not args.execute:
        print(json.dumps(plan, indent=2))
        return 0
    publish_result = publish_mqtt(
        args,
        plan["mqtt"]["topic"],
        plan["mqtt"]["payload"],
        report_topic=f"device/{args.serial}/report" if getattr(args, "serial", "") else None,
    )
    plan["dry_run"] = False
    plan["executed"] = [f"publish_{args.action}_request"]
    plan["mqtt_publish_result"] = publish_result
    print(json.dumps(plan, indent=2))
    return 0


def send_main(args: argparse.Namespace) -> int:
    apply_printer_config(args)
    if args.execute and args.action == "plan":
        raise BambuPrintError("--execute has no effect with --action plan.")
    if args.execute and args.action in {"start", "upload-start"} and not args.confirm_start_print:
        raise BambuPrintError("Starting a print requires --confirm-start-print.")
    if args.execute and args.action in {"start", "upload-start"} and not ensure_printer_serial(args):
        raise BambuPrintError("Printer serial is required to publish a start command.")
    plan = build_send_plan(args)

    if args.handoff == "plain":
        local_path = Path(args.gcode).expanduser().resolve()
        if not args.execute:
            print(json.dumps(plan, indent=2))
            return 0
        return execute_send(args, plan, local_path)

    if not args.execute:
        print(json.dumps(plan, indent=2))
        return 0

    if args.handoff == "template-project":
        with tempfile.TemporaryDirectory(prefix="bambu-template-project-") as tmp:
            project_path = Path(tmp) / default_project_name(Path(args.gcode).expanduser())
            package_result = package_with_template_project(args, project_path)
            plan = build_send_plan(args, project_path=project_path)
            plan["template_project_execution"] = package_result
            return execute_send(args, plan, project_path)

    with tempfile.TemporaryDirectory(prefix="bambu-bambox-") as tmp:
        project_path = Path(tmp) / default_project_name(Path(args.gcode).expanduser())
        package_result = package_with_bambox(args, project_path)
        plan = build_send_plan(args, project_path=project_path)
        plan["bambox_execution"] = package_result
        return execute_send(args, plan, project_path)


def execute_send(args: argparse.Namespace, plan: dict[str, Any], local_path: Path) -> int:
    executed: list[str] = []
    if args.action in {"upload", "upload-start"}:
        upload_ftps(args, local_path, plan["ftps"]["remote_path"])
        executed.append("upload")
    if args.action in {"start", "upload-start"}:
        publish_result = publish_mqtt(
            args,
            plan["mqtt"]["topic"],
            plan["mqtt"]["payload"],
            report_topic=f"device/{args.serial}/report" if getattr(args, "serial", "") else None,
        )
        if isinstance(publish_result, dict):
            plan["mqtt_publish_result"] = publish_result
        executed.append("publish_start_request")
    plan["dry_run"] = False
    plan["local_upload_path"] = str(local_path)
    plan["executed"] = executed
    if "publish_start_request" in executed:
        plan["execution_notes"] = [
            "MQTT start was published as a request; this helper does not confirm printer acceptance or print progress."
        ]
    print(json.dumps(plan, indent=2))
    return 0


def add_package_parser(subparsers: argparse._SubParsersAction) -> None:
    parser = subparsers.add_parser("package", help="Package plain G-code into a supported bambox .gcode.3mf project.")
    parser.add_argument("--gcode", required=True, help="Validated plain .gcode input.")
    parser.add_argument("--output", required=True, help="Output .gcode.3mf path.")
    parser.add_argument("--bambox-profile", required=True, help="Enabled bambox compatibility profile such as p1s-0.4.")
    parser.add_argument("--filament", action="append", help="bambox filament spec. Repeat for multi-filament jobs.")
    parser.add_argument("--bambox-bin", help="Path to bambox CLI. Defaults to BAMBOX_BIN or bambox on PATH.")
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument("--dry-run", dest="execute", action="store_false", help="Print the packaging plan without running bambox.")
    mode.add_argument("--execute", dest="execute", action="store_true", help="Run bambox pack and bambox validate.")
    parser.set_defaults(execute=False, func=package_main)


def add_config_parser(subparsers: argparse._SubParsersAction) -> None:
    parser = subparsers.add_parser("config", help="Read and write local Bambu printer JSON config.")
    parser.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Printer config JSON path.")
    config_subparsers = parser.add_subparsers(dest="config_command", required=True)

    set_parser = config_subparsers.add_parser("set", help="Create or update a configured printer.")
    set_parser.add_argument("--printer", required=True, help="Local printer id, for example a1-mini.")
    set_parser.add_argument("--host", help="Printer LAN IP or hostname.")
    set_parser.add_argument("--access-code", help="Printer LAN access code.")
    set_parser.add_argument("--model", help="Printer model, for example a1-mini.")
    set_parser.add_argument("--serial", help="Printer serial. Usually omitted; use --fetch-serial.")
    set_parser.add_argument("--notes", help="Optional operator notes.")
    set_parser.add_argument("--fetch-serial", action="store_true", help="Fetch and cache serial from the printer TLS certificate.")
    set_parser.add_argument("--mqtt-port", type=int, default=DEFAULT_MQTT_PORT)
    set_parser.add_argument("--timeout", type=float, default=20.0)
    set_parser.add_argument("--tls-verify", action="store_true", help="Verify printer TLS certificates.")
    set_parser.add_argument(
        "--allow-nonprivate-host",
        action="store_true",
        help="Allow printer hosts that resolve outside private/link-local/loopback ranges.",
    )
    set_parser.set_defaults(func=config_set_main)

    list_parser = config_subparsers.add_parser("list", help="List configured printers.")
    list_parser.set_defaults(func=config_list_main)

    show_parser = config_subparsers.add_parser("show", help="Show one configured printer without printing the access code.")
    show_parser.add_argument("--printer", required=True)
    show_parser.set_defaults(func=config_show_main)


def add_serial_parser(subparsers: argparse._SubParsersAction) -> None:
    parser = subparsers.add_parser("serial", help="Fetch the printer serial from its local TLS certificate.")
    parser.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Printer config JSON path.")
    parser.add_argument("--printer", help="Configured printer id.")
    parser.add_argument("--host", default="", help="Printer LAN IP or hostname.")
    parser.add_argument("--mqtt-port", type=int, default=DEFAULT_MQTT_PORT)
    parser.add_argument("--timeout", type=float, default=20.0)
    parser.add_argument("--tls-verify", action="store_true", help="Verify printer TLS certificates.")
    parser.add_argument(
        "--allow-nonprivate-host",
        action="store_true",
        help="Allow printer hosts that resolve outside private/link-local/loopback ranges.",
    )
    parser.add_argument("--json", action="store_true")
    parser.set_defaults(func=serial_main)


def add_status_parser(subparsers: argparse._SubParsersAction) -> None:
    parser = subparsers.add_parser("status", help="Read one MQTT report message from a configured local printer.")
    parser.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Printer config JSON path.")
    parser.add_argument("--printer", help="Configured printer id.")
    parser.add_argument("--host", default="", help="Printer LAN IP or hostname.")
    parser.add_argument(
        "--serial",
        default="",
        help="Printer serial number for MQTT topic. Usually loaded from config or fetched automatically.",
    )
    parser.add_argument("--access-code", help="Printer access code for one-off use. Prefer --printer config.")
    parser.add_argument("--topic", help="MQTT report topic. Defaults to device/{serial}/report.")
    parser.add_argument("--push-all", action="store_true", help="Request a full status report after subscribing.")
    parser.add_argument("--wait-seconds", type=float, default=10.0)
    parser.add_argument("--max-messages", type=int, default=1, help="Maximum report messages to collect before returning.")
    parser.add_argument("--sequence-id", help="MQTT sequence id for --push-all. Defaults to current timestamp milliseconds.")
    parser.add_argument("--mqtt-port", type=int, default=DEFAULT_MQTT_PORT)
    parser.add_argument("--timeout", type=float, default=20.0)
    parser.add_argument("--tls-verify", action="store_true", help="Verify printer TLS certificates.")
    parser.add_argument(
        "--allow-nonprivate-host",
        action="store_true",
        help="Allow printer hosts that resolve outside private/link-local/loopback ranges.",
    )
    parser.add_argument("--client-id", help="MQTT client id. Defaults to the printer serial.")
    parser.set_defaults(func=status_main)


def add_clear_error_parser(subparsers: argparse._SubParsersAction) -> None:
    parser = subparsers.add_parser("clear-error", help="Publish Bambu clean_print_error; dry-run by default.")
    parser.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Printer config JSON path.")
    parser.add_argument("--printer", help="Configured printer id.")
    parser.add_argument("--host", default="", help="Printer LAN IP or hostname.")
    parser.add_argument(
        "--serial",
        default="",
        help="Printer serial number for MQTT topic. Usually loaded from config or fetched automatically.",
    )
    parser.add_argument("--access-code", help="Printer access code for one-off use. Prefer --printer config.")
    parser.add_argument("--execute", action="store_true", help="Publish clean_print_error to the printer.")
    parser.add_argument("--mqtt-port", type=int, default=DEFAULT_MQTT_PORT)
    parser.add_argument("--timeout", type=float, default=20.0)
    parser.add_argument("--tls-verify", action="store_true", help="Verify printer TLS certificates.")
    parser.add_argument(
        "--allow-nonprivate-host",
        action="store_true",
        help="Allow printer hosts that resolve outside private/link-local/loopback ranges.",
    )
    parser.add_argument("--client-id", help="MQTT client id. Defaults to the printer serial.")
    parser.add_argument("--sequence-id", help="MQTT sequence id. Defaults to current timestamp milliseconds.")
    parser.add_argument(
        "--mqtt-qos",
        type=int,
        choices=[0, 1],
        default=0,
        help="MQTT publish QoS. Default 0; use 1 for protocol diagnostics.",
    )
    parser.add_argument(
        "--wait-after-publish",
        type=float,
        default=0.0,
        help="After publishing, stay connected for this many seconds and capture report messages.",
    )
    parser.set_defaults(func=clear_error_main)


def add_print_control_parser(subparsers: argparse._SubParsersAction, action: str) -> None:
    help_text = "Pause the active local print; dry-run by default." if action == "pause" else "Cancel the active local print; dry-run by default."
    parser = subparsers.add_parser(action, help=help_text)
    parser.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Printer config JSON path.")
    parser.add_argument("--printer", help="Configured printer id.")
    parser.add_argument("--host", default="", help="Printer LAN IP or hostname.")
    parser.add_argument(
        "--serial",
        default="",
        help="Printer serial number for MQTT topic. Usually loaded from config or fetched automatically.",
    )
    parser.add_argument("--access-code", help="Printer access code for one-off use. Prefer --printer config.")
    parser.add_argument("--execute", action="store_true", help=f"Publish the {action} request to the printer.")
    if action == "cancel":
        parser.add_argument(
            "--confirm-cancel-print",
            action="store_true",
            help="Required with --execute because canceling stops the active print job.",
        )
    parser.add_argument("--mqtt-port", type=int, default=DEFAULT_MQTT_PORT)
    parser.add_argument("--timeout", type=float, default=20.0)
    parser.add_argument("--tls-verify", action="store_true", help="Verify printer TLS certificates.")
    parser.add_argument(
        "--allow-nonprivate-host",
        action="store_true",
        help="Allow printer hosts that resolve outside private/link-local/loopback ranges.",
    )
    parser.add_argument("--client-id", help="MQTT client id. Defaults to the printer serial.")
    parser.add_argument("--sequence-id", help="MQTT sequence id. Defaults to current timestamp milliseconds.")
    parser.add_argument(
        "--mqtt-qos",
        type=int,
        choices=[0, 1],
        default=0,
        help="MQTT publish QoS. Default 0; use 1 for protocol diagnostics.",
    )
    parser.add_argument(
        "--wait-after-publish",
        type=float,
        default=0.0,
        help="After publishing, stay connected for this many seconds and capture report messages.",
    )
    parser.set_defaults(action=action, func=print_control_main)


def add_send_parser(subparsers: argparse._SubParsersAction) -> None:
    parser = subparsers.add_parser("send", help="Inspect, upload, and optionally start a validated plain G-code job.")
    parser.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Printer config JSON path.")
    parser.add_argument("--printer", help="Configured printer id. Preferred over repeating host/access-code flags.")
    parser.add_argument("--gcode", required=True, help="Validated plain .gcode input.")
    parser.add_argument("--handoff", choices=["plain", "bambox-project", "template-project"], default="plain")
    parser.add_argument("--host", default="", help="Printer LAN IP or hostname.")
    parser.add_argument(
        "--serial",
        default="",
        help="Printer serial number for MQTT topic. Usually omitted; the helper fetches it from the printer TLS certificate.",
    )
    parser.add_argument("--access-code", help="Printer access code for one-off use. Prefer --printer config for repeated use.")
    parser.add_argument("--action", choices=["plan", "upload", "start", "upload-start"], default="plan")
    parser.add_argument("--execute", action="store_true", help="Perform network action selected by --action.")
    parser.add_argument("--confirm-start-print", action="store_true", help="Required for --action start or upload-start with --execute.")
    parser.add_argument(
        "--remote-dir",
        default=None,
        help="Remote directory. Defaults to cache for plain G-code and printer root for project handoffs.",
    )
    parser.add_argument("--remote-name", help="Filename to store on the printer.")
    parser.add_argument("--gcode-param", help="Override MQTT gcode_file param. Defaults to the uploaded remote path.")
    parser.add_argument("--plate", type=int, help="Plate number for packaged projects. Defaults to the first plate.")
    parser.add_argument("--bambox-profile", default="p1s-0.4", help="Enabled bambox profile for --handoff bambox-project.")
    parser.add_argument("--filament", action="append", help="bambox filament spec. Repeat for multi-filament jobs.")
    parser.add_argument("--bambox-bin", help="Path to bambox CLI. Defaults to BAMBOX_BIN or bambox on PATH.")
    parser.add_argument(
        "--template-project",
        help="Same-printer sliced .gcode.3mf template for --handoff template-project.",
    )
    parser.add_argument("--ftps-port", type=int, default=DEFAULT_FTPS_PORT)
    parser.add_argument("--mqtt-port", type=int, default=DEFAULT_MQTT_PORT)
    parser.add_argument("--timeout", type=float, default=20.0)
    parser.add_argument("--tls-verify", action="store_true", help="Verify printer TLS certificates.")
    parser.add_argument(
        "--allow-nonprivate-host",
        action="store_true",
        help="Allow printer hosts that resolve outside private/link-local/loopback ranges.",
    )
    parser.add_argument("--client-id", help="MQTT client id. Defaults to the printer serial for start commands.")
    parser.add_argument("--sequence-id", help="MQTT sequence id. Defaults to current timestamp milliseconds.")
    parser.add_argument(
        "--mqtt-qos",
        type=int,
        choices=[0, 1],
        default=0,
        help="MQTT publish QoS for start commands. Default 0; use 1 for protocol diagnostics.",
    )
    parser.add_argument(
        "--wait-after-publish",
        type=float,
        default=0.0,
        help="After publishing a start command, stay connected for this many seconds and capture report messages.",
    )
    parser.set_defaults(func=send_main)


def parse_args(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)
    add_package_parser(subparsers)
    add_config_parser(subparsers)
    add_serial_parser(subparsers)
    add_status_parser(subparsers)
    add_clear_error_parser(subparsers)
    add_print_control_parser(subparsers, "pause")
    add_print_control_parser(subparsers, "cancel")
    add_send_parser(subparsers)
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv if argv is not None else sys.argv[1:])
    try:
        return args.func(args)
    except BambuPrintError as exc:
        print(json.dumps({"error": str(exc)}, indent=2), file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
