#!/usr/bin/env python3
"""Dry-run-first helpers for generating and validating plain FDM G-code."""

from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any


DIRECT_MESH_EXTENSIONS = {".stl", ".obj", ".3mf"}
CONVERT_TO_STL_EXTENSIONS = {".ply", ".glb", ".gltf"}
UNSUPPORTED_EXTENSIONS = {".step", ".stp", ".dxf", ".svg", ".urdf", ".sdf"}
SLICED_BAMBU_PLATE_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")

PREFERRED_BACKEND_ORDER = ["orcaslicer", "prusa-slicer", "curaengine"]
BACKEND_EXECUTABLES = {
    "orcaslicer": ["OrcaSlicer", "orca-slicer", "orcaslicer"],
    "prusa-slicer": ["prusa-slicer", "PrusaSlicer", "prusa-slicer-console"],
    "curaengine": ["CuraEngine", "curaengine"],
}
BACKEND_APP_GLOBS = {
    "orcaslicer": ["OrcaSlicer*.app/Contents/MacOS/OrcaSlicer"],
    "prusa-slicer": ["PrusaSlicer*.app/Contents/MacOS/PrusaSlicer"],
}
BACKEND_ENV_VARS = {
    "orcaslicer": "ORCASLICER_BIN",
    "prusa-slicer": "PRUSASLICER_BIN",
    "curaengine": "CURAENGINE_BIN",
}
BAMBU_EXECUTABLES = ["bambu-studio", "BambuStudio"]
SUPPORTED_GCODE_COMMANDS = {
    "G0",
    "G1",
    "G2",
    "G3",
    "G4",
    "G21",
    "G28",
    "G29",
    "G90",
    "G91",
    "G92",
    "M18",
    "M73",
    "M82",
    "M83",
    "M84",
    "M104",
    "M106",
    "M107",
    "M109",
    "M117",
    "M118",
    "M140",
    "M190",
    "M201",
    "M203",
    "M204",
    "M205",
    "M220",
    "M221",
    "M400",
    "M500",
    "M501",
    "M900",
}


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


@dataclass(frozen=True)
class BackendDiscovery:
    id: str
    available: bool
    path: str | None
    preferred: bool
    reason: str = ""


@dataclass(frozen=True)
class GCodeProfile:
    backend: str
    native_config: str
    native_settings: tuple[str, ...]
    native_filaments: tuple[str, ...]
    machine_name: str
    bed_size_mm: tuple[float, float]
    z_height_mm: float
    motion_bounds_mm: dict[str, tuple[float, float]]
    filament_type: str
    nozzle_temp_c: float
    bed_temp_c: float


@dataclass(frozen=True)
class InputInspection:
    path: str
    extension: str
    size_bytes: int
    supported: bool
    direct_to_slicer: bool
    needs_stl_conversion: bool
    already_sliced_bambu: bool
    status: str


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 find_executable(names: list[str], *, search_path: str | None = None) -> str | None:
    for name in names:
        found = shutil.which(name, path=search_path)
        if found:
            return found
    return None


def find_app_executable(backend: str) -> str | None:
    applications = Path("/Applications")
    if not applications.exists():
        return None
    for pattern in BACKEND_APP_GLOBS.get(backend, []):
        for path in sorted(applications.glob(pattern)):
            if path.is_file():
                return str(path)
    return None


def find_backend_executable(backend: str, *, search_path: str | None = None) -> str | None:
    env_value = os.environ.get(BACKEND_ENV_VARS[backend], "").strip()
    if env_value:
        return env_value
    return find_executable(BACKEND_EXECUTABLES[backend], search_path=search_path) or find_app_executable(backend)


def discover_backends(*, search_path: str | None = None) -> list[BackendDiscovery]:
    discoveries: list[BackendDiscovery] = []
    for backend_id in PREFERRED_BACKEND_ORDER:
        path = find_backend_executable(backend_id, search_path=search_path)
        discoveries.append(
            BackendDiscovery(
                id=backend_id,
                available=bool(path),
                path=path or None,
                preferred=True,
            )
        )
    return discoveries


def discover_bambu_studio(*, search_path: str | None = None) -> list[BackendDiscovery]:
    paths: list[str] = []
    env_value = os.environ.get("BAMBU_STUDIO_BIN", "").strip()
    if env_value:
        paths.append(env_value)
    which_value = find_executable(BAMBU_EXECUTABLES, search_path=search_path)
    if which_value:
        paths.append(which_value)
    applications = Path("/Applications")
    if applications.exists():
        for pattern in ("BambuStudio*.app/Contents/MacOS/BambuStudio", "Bambu Studio*.app/Contents/MacOS/BambuStudio"):
            paths.extend(str(path) for path in applications.glob(pattern))

    unique_paths = sorted(dict.fromkeys(paths))
    if not unique_paths:
        return [
            BackendDiscovery(
                id="bambu-studio",
                available=False,
                path=None,
                preferred=False,
                reason="not preferred because Bambu Studio CLI has shown macOS export instability",
            )
        ]
    return [
        BackendDiscovery(
            id="bambu-studio",
            available=True,
            path=path,
            preferred=False,
            reason="available but not preferred because Bambu Studio CLI has shown macOS export instability",
        )
        for path in unique_paths
    ]


def discovery_report(*, search_path: str | None = None) -> dict[str, Any]:
    backends = discover_backends(search_path=search_path)
    not_preferred = discover_bambu_studio(search_path=search_path)
    return {
        "preferred_order": PREFERRED_BACKEND_ORDER,
        "backends": [asdict(item) for item in backends],
        "not_preferred": [asdict(item) for item in not_preferred],
    }


def load_json(path: Path) -> dict[str, Any]:
    try:
        with path.open("r", encoding="utf-8") as handle:
            data = json.load(handle)
    except FileNotFoundError as exc:
        raise GCodeToolError(f"Profile does not exist: {path}") from exc
    except json.JSONDecodeError as exc:
        raise GCodeToolError(f"Profile is not valid JSON: {path}: {exc}") from exc
    if not isinstance(data, dict):
        raise GCodeToolError("Profile JSON must be an object.")
    return data


def require_number(value: Any, label: str) -> float:
    if not isinstance(value, (int, float)) or isinstance(value, bool):
        raise GCodeToolError(f"Profile field {label} must be a number.")
    number = float(value)
    if number <= 0:
        raise GCodeToolError(f"Profile field {label} must be greater than zero.")
    return number


def require_absolute_file(path_value: str, label: str) -> str:
    profile_path = Path(path_value).expanduser()
    if not profile_path.is_absolute():
        raise GCodeToolError(f"Profile field {label} must be an absolute path.")
    if not profile_path.is_file():
        raise GCodeToolError(f"Profile {label} does not exist: {profile_path}")
    return str(profile_path)


def optional_path_list(data: dict[str, Any], field: str) -> tuple[str, ...]:
    value = data.get(field)
    if value is None:
        return ()
    if isinstance(value, str):
        values = [value]
    elif isinstance(value, list) and all(isinstance(item, str) for item in value):
        values = value
    else:
        raise GCodeToolError(f"Profile field {field} must be a string path or list of string paths.")
    paths = [item.strip() for item in values if item.strip()]
    if not paths:
        raise GCodeToolError(f"Profile field {field} cannot be empty when provided.")
    return tuple(require_absolute_file(path, field) for path in paths)


def parse_axis_bounds(value: Any, label: str) -> tuple[float, float]:
    if (
        not isinstance(value, list)
        or len(value) != 2
        or any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in value)
    ):
        raise GCodeToolError(f"Profile field {label} must be [min, max].")
    lower = float(value[0])
    upper = float(value[1])
    if lower >= upper:
        raise GCodeToolError(f"Profile field {label} must have min less than max.")
    return lower, upper


def load_profile(path: Path) -> GCodeProfile:
    data = load_json(path)
    backend = str(data.get("backend") or "").strip().lower()
    if backend not in PREFERRED_BACKEND_ORDER:
        raise GCodeToolError(f"Profile backend must be one of: {', '.join(PREFERRED_BACKEND_ORDER)}.")

    native_config = str(data.get("native_config") or "").strip()
    if not native_config:
        raise GCodeToolError("Profile field native_config is required.")
    native_config = require_absolute_file(native_config, "native_config")
    native_settings = optional_path_list(data, "native_settings") or (native_config,)
    native_filaments = optional_path_list(data, "native_filaments")

    machine = data.get("machine")
    if not isinstance(machine, dict):
        raise GCodeToolError("Profile field machine must be an object.")
    machine_name = str(machine.get("name") or "").strip()
    if not machine_name:
        raise GCodeToolError("Profile field machine.name is required.")
    bed_size = machine.get("bed_size_mm")
    if (
        not isinstance(bed_size, list)
        or len(bed_size) != 2
        or any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in bed_size)
    ):
        raise GCodeToolError("Profile field machine.bed_size_mm must be [width, depth].")
    bed_x = require_number(bed_size[0], "machine.bed_size_mm[0]")
    bed_y = require_number(bed_size[1], "machine.bed_size_mm[1]")
    z_height = require_number(machine.get("z_height_mm"), "machine.z_height_mm")
    motion_bounds = {
        "x": (0.0, bed_x),
        "y": (0.0, bed_y),
        "z": (0.0, z_height),
    }
    if "motion_bounds_mm" in machine:
        raw_bounds = machine.get("motion_bounds_mm")
        if not isinstance(raw_bounds, dict):
            raise GCodeToolError("Profile field machine.motion_bounds_mm must be an object.")
        for axis in ("x", "y", "z"):
            if axis in raw_bounds:
                motion_bounds[axis] = parse_axis_bounds(raw_bounds[axis], f"machine.motion_bounds_mm.{axis}")

    filament = data.get("filament")
    if not isinstance(filament, dict):
        raise GCodeToolError("Profile field filament must be an object.")
    filament_type = str(filament.get("type") or "").strip()
    if not filament_type:
        raise GCodeToolError("Profile field filament.type is required.")
    nozzle_temp = require_number(filament.get("nozzle_temp_c"), "filament.nozzle_temp_c")
    bed_temp = require_number(filament.get("bed_temp_c"), "filament.bed_temp_c")

    return GCodeProfile(
        backend=backend,
        native_config=native_config,
        native_settings=native_settings,
        native_filaments=native_filaments,
        machine_name=machine_name,
        bed_size_mm=(bed_x, bed_y),
        z_height_mm=z_height,
        motion_bounds_mm=motion_bounds,
        filament_type=filament_type,
        nozzle_temp_c=nozzle_temp,
        bed_temp_c=bed_temp,
    )


def is_sliced_bambu_3mf(path: Path) -> bool:
    try:
        with zipfile.ZipFile(path) as archive:
            return any(SLICED_BAMBU_PLATE_RE.match(info.filename) for info in archive.infolist())
    except zipfile.BadZipFile:
        return False


def inspect_input(path: Path) -> InputInspection:
    resolved = path.expanduser().resolve()
    if not resolved.is_file():
        raise GCodeToolError(f"Input file does not exist: {resolved}")

    extension = resolved.suffix.lower()
    if extension in UNSUPPORTED_EXTENSIONS:
        supported = ", ".join(sorted(DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS))
        raise GCodeToolError(f"{extension} is out of scope for this skill. Supported v1 mesh inputs: {supported}.")
    if extension not in DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS:
        supported = ", ".join(sorted(DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS))
        raise GCodeToolError(f"Unsupported input extension {extension or '<none>'}. Supported v1 mesh inputs: {supported}.")

    already_sliced = extension == ".3mf" and is_sliced_bambu_3mf(resolved)
    if already_sliced:
        status = "already_sliced_bambu_3mf"
    elif extension in DIRECT_MESH_EXTENSIONS:
        status = "direct_to_slicer"
    else:
        status = "requires_stl_conversion"

    return InputInspection(
        path=str(resolved),
        extension=extension,
        size_bytes=resolved.stat().st_size,
        supported=True,
        direct_to_slicer=extension in DIRECT_MESH_EXTENSIONS and not already_sliced,
        needs_stl_conversion=extension in CONVERT_TO_STL_EXTENSIONS,
        already_sliced_bambu=already_sliced,
        status=status,
    )


def backend_path(backend: str, *, search_path: str | None = None) -> str:
    path = find_backend_executable(backend, search_path=search_path)
    if not path:
        names = ", ".join(BACKEND_EXECUTABLES[backend])
        raise GCodeToolError(f"Backend {backend} is not installed or not on PATH. Checked: {names}.")
    return path


def build_backend_command(
    *,
    backend: str,
    executable: str,
    slicer_input: Path,
    output: Path,
    profile: GCodeProfile,
) -> list[str]:
    if backend == "orcaslicer":
        command = [
            executable,
            "--load-settings",
            ";".join(profile.native_settings),
        ]
        if profile.native_filaments:
            command.extend(["--load-filaments", ";".join(profile.native_filaments)])
        command.extend(
            [
                "--outputdir",
                str(output.parent),
                "--slice",
                "0",
                str(slicer_input),
            ]
        )
        return command
    if backend == "prusa-slicer":
        return [
            executable,
            "--load",
            profile.native_config,
            "--export-gcode",
            "--output",
            str(output),
            str(slicer_input),
        ]
    if backend == "curaengine":
        return [
            executable,
            "slice",
            "-j",
            profile.native_config,
            "-l",
            str(slicer_input),
            "-o",
            str(output),
        ]
    raise GCodeToolError(f"Unsupported backend: {backend}")


def generated_gcode_candidate(output: Path, slicer_input: Path, existing_gcodes: set[Path]) -> Path | None:
    expected_by_input = output.parent / f"{slicer_input.stem}.gcode"
    if expected_by_input.is_file() and expected_by_input.resolve() != output.resolve():
        return expected_by_input
    generated = [
        path
        for path in output.parent.glob("*.gcode")
        if path.resolve() not in existing_gcodes and path.resolve() != output.resolve()
    ]
    if len(generated) == 1:
        return generated[0]
    return None


def build_slice_plan(args: argparse.Namespace, *, search_path: str | None = None, converted_input: Path | None = None) -> dict[str, Any]:
    profile = load_profile(Path(args.profile).expanduser())
    inspection = inspect_input(Path(args.input))
    if inspection.already_sliced_bambu:
        raise GCodeToolError("Input is already a sliced Bambu .gcode.3mf. Refusing to re-slice it.")

    backend = profile.backend if args.backend == "auto" else args.backend
    if backend not in PREFERRED_BACKEND_ORDER:
        raise GCodeToolError(f"Backend must be auto or one of: {', '.join(PREFERRED_BACKEND_ORDER)}.")
    executable = backend_path(backend, search_path=search_path)

    input_path = Path(inspection.path)
    slicer_input = converted_input or input_path
    if inspection.needs_stl_conversion and converted_input is None:
        slicer_input = Path(tempfile.gettempdir()) / f"{input_path.stem}.converted.stl"
    output = Path(args.output).expanduser().resolve()
    command = build_backend_command(
        backend=backend,
        executable=executable,
        slicer_input=slicer_input,
        output=output,
        profile=profile,
    )

    return {
        "dry_run": not args.execute,
        "backend": backend,
        "executable": executable,
        "command": command,
        "input": asdict(inspection),
        "output": str(output),
        "profile": asdict(profile),
        "conversion": {
            "required": inspection.needs_stl_conversion,
            "slicer_input": str(slicer_input),
            "note": "Requires trimesh at execution time." if inspection.needs_stl_conversion else "",
        },
    }


def convert_mesh_to_stl(input_path: Path, output_path: Path) -> None:
    try:
        import trimesh  # type: ignore
    except ImportError as exc:
        raise GCodeToolError(
            "trimesh is required to convert .ply/.glb/.gltf inputs to temporary STL. "
            "Install it in the active Python environment or provide .stl/.obj/.3mf directly."
        ) from exc

    loaded = trimesh.load(str(input_path))
    if getattr(loaded, "is_empty", False):
        raise GCodeToolError(f"Input mesh appears empty: {input_path}")
    if hasattr(loaded, "dump"):
        mesh = loaded.dump(concatenate=True)
    else:
        mesh = loaded
    if getattr(mesh, "is_empty", False):
        raise GCodeToolError(f"Input mesh appears empty after scene conversion: {input_path}")
    output_path.parent.mkdir(parents=True, exist_ok=True)
    mesh.export(str(output_path), file_type="stl")


def execute_slice(args: argparse.Namespace, *, search_path: str | None = None) -> tuple[int, dict[str, Any]]:
    input_inspection = inspect_input(Path(args.input))
    output = Path(args.output).expanduser().resolve()
    output.parent.mkdir(parents=True, exist_ok=True)

    with tempfile.TemporaryDirectory(prefix="gcode-slice-") as tmp:
        converted_input = None
        if input_inspection.needs_stl_conversion:
            converted_input = Path(tmp) / f"{Path(input_inspection.path).stem}.stl"
            convert_mesh_to_stl(Path(input_inspection.path), converted_input)
        plan = build_slice_plan(args, search_path=search_path, converted_input=converted_input)
        slicer_input = Path(plan["conversion"]["slicer_input"])
        existing_gcodes = {path.resolve() for path in output.parent.glob("*.gcode")}
        result = subprocess.run(plan["command"], check=False, capture_output=True, text=True)

    plan["returncode"] = result.returncode
    if result.stdout:
        plan["stdout_tail"] = tail_text(result.stdout)
    if result.stderr:
        plan["stderr_tail"] = tail_text(result.stderr)
    if result.returncode != 0:
        return result.returncode, plan
    if not output.is_file():
        candidate = generated_gcode_candidate(output, slicer_input, existing_gcodes)
        if candidate is None:
            plan["error"] = "Slicer exited successfully but did not create the requested output file."
            return 1, plan
        candidate.replace(output)
        plan["renamed_output_from"] = str(candidate)
    plan["generated"] = {"path": str(output), "size_bytes": output.stat().st_size}
    return 0, plan


def strip_gcode_comment(line: str) -> str:
    return line.split(";", 1)[0].strip()


def parse_command(line: str) -> str:
    stripped = strip_gcode_comment(line)
    if not stripped:
        return ""
    first = stripped.split(None, 1)[0].upper()
    if re.fullmatch(r"[GMT]\d+(?:\.\d+)?", first):
        if "." in first:
            first = first.split(".", 1)[0]
        return first
    return first


def parse_numeric_tokens(line: str) -> dict[str, float]:
    stripped = strip_gcode_comment(line).upper()
    tokens: dict[str, float] = {}
    for key, value in re.findall(r"([A-Z])([-+]?(?:\d+(?:\.\d*)?|\.\d+))", stripped):
        try:
            tokens[key] = float(value)
        except ValueError:
            continue
    return tokens


def validate_gcode_file(path: Path, profile: GCodeProfile) -> dict[str, Any]:
    resolved = path.expanduser().resolve()
    if not resolved.is_file():
        raise GCodeToolError(f"G-code file does not exist: {resolved}")
    text = resolved.read_text(encoding="utf-8", errors="replace")
    lines = text.splitlines()
    errors: list[str] = []
    warnings: list[str] = []
    unknown_commands: dict[str, int] = {}
    stats = {
        "lines": len(lines),
        "non_comment_lines": 0,
        "movement_commands": 0,
        "extrusion_moves": 0,
        "temperature_commands": 0,
    }

    if not text.strip():
        errors.append("G-code file is empty.")

    absolute_positioning = True
    x_min, x_max = profile.motion_bounds_mm["x"]
    y_min, y_max = profile.motion_bounds_mm["y"]
    z_min, z_max = profile.motion_bounds_mm["z"]
    for line_number, line in enumerate(lines, start=1):
        command = parse_command(line)
        if not command:
            continue
        stats["non_comment_lines"] += 1
        tokens = parse_numeric_tokens(line)

        if command not in SUPPORTED_GCODE_COMMANDS and not re.fullmatch(r"T\d+", command):
            unknown_commands.setdefault(command, line_number)

        if command == "G90":
            absolute_positioning = True
        elif command == "G91":
            absolute_positioning = False
            if "Relative positioning found; XYZ bounds validation is skipped while relative mode is active." not in warnings:
                warnings.append("Relative positioning found; XYZ bounds validation is skipped while relative mode is active.")

        if command in {"G0", "G1", "G2", "G3"}:
            stats["movement_commands"] += 1
            if command == "G1" and "E" in tokens:
                stats["extrusion_moves"] += 1
            if absolute_positioning:
                if "X" in tokens and not x_min <= tokens["X"] <= x_max:
                    errors.append(f"Line {line_number}: X={tokens['X']} is outside X motion range {x_min}..{x_max} mm.")
                if "Y" in tokens and not y_min <= tokens["Y"] <= y_max:
                    errors.append(f"Line {line_number}: Y={tokens['Y']} is outside Y motion range {y_min}..{y_max} mm.")
                if "Z" in tokens and not z_min <= tokens["Z"] <= z_max:
                    errors.append(f"Line {line_number}: Z={tokens['Z']} is outside Z motion range {z_min}..{z_max} mm.")
        if command in {"M104", "M109", "M140", "M190"}:
            stats["temperature_commands"] += 1

    if stats["movement_commands"] == 0:
        errors.append("No G0/G1/G2/G3 movement commands found.")
    if stats["extrusion_moves"] == 0:
        errors.append("No extrusion moves found.")
    if stats["temperature_commands"] == 0:
        errors.append("No nozzle or bed temperature commands found.")
    if unknown_commands:
        sample = ", ".join(f"{cmd} line {line}" for cmd, line in sorted(unknown_commands.items())[:12])
        warnings.append(f"Unknown or unsupported commands were left unchanged: {sample}.")

    return {
        "ok": not errors,
        "path": str(resolved),
        "profile": asdict(profile),
        "errors": errors,
        "warnings": warnings,
        "stats": stats,
    }


def print_json(payload: Any) -> None:
    print(json.dumps(payload, indent=2, sort_keys=True))


def discover_main(args: argparse.Namespace) -> int:
    print_json(discovery_report(search_path=args.search_path))
    return 0


def inspect_main(args: argparse.Namespace) -> int:
    inspection = inspect_input(Path(args.input))
    payload = asdict(inspection)
    if args.json:
        print_json(payload)
    else:
        print(f"{payload['status']}: {payload['path']}")
    return 0


def slice_main(args: argparse.Namespace) -> int:
    if args.execute and args.dry_run:
        raise GCodeToolError("--execute and --dry-run cannot be used together.")
    if not args.execute:
        plan = build_slice_plan(args, search_path=args.search_path)
        plan["dry_run"] = True
        print_json(plan)
        return 0

    code, result = execute_slice(args, search_path=args.search_path)
    result["dry_run"] = False
    print_json(result)
    return code


def validate_main(args: argparse.Namespace) -> int:
    profile = load_profile(Path(args.profile).expanduser())
    result = validate_gcode_file(Path(args.gcode), profile)
    if args.json:
        print_json(result)
    else:
        status = "ok" if result["ok"] else "failed"
        print(f"Validation {status}: {result['path']}")
        for error in result["errors"]:
            print(f"error: {error}")
        for warning in result["warnings"]:
            print(f"warning: {warning}")
    return 0 if result["ok"] else 1


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)

    discover = subparsers.add_parser("discover", help="Report installed slicer backends.")
    discover.add_argument("--search-path", help=argparse.SUPPRESS)
    discover.set_defaults(func=discover_main)

    inspect = subparsers.add_parser("inspect", help="Classify a mesh input before slicing.")
    inspect.add_argument("--input", required=True)
    inspect.add_argument("--json", action="store_true")
    inspect.set_defaults(func=inspect_main)

    slice_parser = subparsers.add_parser("slice", help="Build or run a slicer CLI command.")
    slice_parser.add_argument("--input", required=True)
    slice_parser.add_argument("--output", required=True)
    slice_parser.add_argument("--profile", required=True)
    slice_parser.add_argument("--backend", default="auto", choices=["auto", *PREFERRED_BACKEND_ORDER])
    slice_parser.add_argument("--dry-run", action="store_true", help="Print the planned slicer command without running it.")
    slice_parser.add_argument("--execute", action="store_true", help="Run the slicer command and write local .gcode.")
    slice_parser.add_argument("--search-path", help=argparse.SUPPRESS)
    slice_parser.set_defaults(func=slice_main)

    validate = subparsers.add_parser("validate", help="Statically validate generated G-code.")
    validate.add_argument("--gcode", required=True)
    validate.add_argument("--profile", required=True)
    validate.add_argument("--json", action="store_true")
    validate.set_defaults(func=validate_main)
    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        return args.func(args)
    except GCodeToolError as exc:
        print_json({"ok": False, "error": str(exc)})
        return 2


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