"""CLI entry point for fabprint."""

from __future__ import annotations

import argparse
import logging
import os
import sys
from pathlib import Path

from fabprint import __version__
from fabprint.arrange import arrange
from fabprint.config import FabprintConfig, load_config
from fabprint.loader import extract_paint_colors, load_mesh
from fabprint.orient import orient_mesh
from fabprint.plate import build_plate, export_plate


def main(argv: list[str] | None = None) -> None:
    parser = argparse.ArgumentParser(
        prog="fabprint",
        description="Headless 3D print pipeline: arrange, slice, and print",
    )
    parser.add_argument("--version", action="version", version=f"fabprint {__version__}")
    sub = parser.add_subparsers(dest="command")

    # Shared args for subcommands
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging")
    common.add_argument(
        "--scale",
        type=float,
        default=None,
        help="Scale all parts by this factor (multiplies per-part scale)",
    )

    # plate subcommand
    plate_cmd = sub.add_parser(
        "plate", parents=[common], help="Arrange parts and export a 3MF plate"
    )
    plate_cmd.add_argument("config", type=Path, help="Path to fabprint.toml")
    plate_cmd.add_argument("-o", "--output", type=Path, default=None, help="Output 3MF path")
    plate_cmd.add_argument("--view", action="store_true", help="Show plate in viewer")

    # slice subcommand
    slice_cmd = sub.add_parser(
        "slice", parents=[common], help="Arrange, export, and slice to gcode"
    )
    slice_cmd.add_argument("config", type=Path, help="Path to fabprint.toml")
    slice_cmd.add_argument("-o", "--output-dir", type=Path, default=None, help="Output directory")
    slice_cmd.add_argument(
        "--view", action="store_true", help="Show plate in viewer before slicing"
    )
    slice_cmd.add_argument(
        "--docker",
        action="store_true",
        help="Force slicing via Docker (even if local slicer is available)",
    )
    slice_cmd.add_argument(
        "--docker-version",
        type=str,
        default=None,
        help="Use a specific OrcaSlicer Docker image version (e.g. 2.3.1)",
    )
    slice_cmd.add_argument(
        "--filament-type",
        type=str,
        default=None,
        help="Override filament profile name (e.g. 'Generic PLA @base')",
    )
    slice_cmd.add_argument(
        "--filament-slot",
        type=int,
        default=1,
        help="AMS slot for --filament-type (default: 1)",
    )

    # print subcommand
    print_cmd = sub.add_parser(
        "print", parents=[common], help="Arrange, slice, and send to printer"
    )
    print_cmd.add_argument("config", type=Path, help="Path to fabprint.toml")
    print_cmd.add_argument(
        "--gcode", type=Path, default=None, help="Send pre-sliced gcode (skip arrange/slice)"
    )
    print_cmd.add_argument("-o", "--output-dir", type=Path, default=None, help="Output directory")
    print_cmd.add_argument(
        "--dry-run", action="store_true", help="Do everything except send to printer"
    )
    print_cmd.add_argument(
        "--upload-only",
        action="store_true",
        help="Upload gcode but don't start printing (start from touchscreen/app)",
    )
    print_cmd.add_argument(
        "--view", action="store_true", help="Show plate in viewer before slicing"
    )
    print_cmd.add_argument(
        "--experimental",
        action="store_true",
        help="Enable experimental printer modes (e.g. cloud-http, which lacks request signing)",
    )
    print_cmd.add_argument(
        "--docker",
        action="store_true",
        help="Force slicing via Docker (even if local slicer is available)",
    )
    print_cmd.add_argument(
        "--docker-version",
        type=str,
        default=None,
        help="Use a specific OrcaSlicer Docker image version (e.g. 2.3.1)",
    )
    print_cmd.add_argument(
        "--filament-type",
        type=str,
        default=None,
        help="Override filament profile name (e.g. 'Generic PLA @base')",
    )
    print_cmd.add_argument(
        "--filament-slot",
        type=int,
        default=1,
        help="AMS slot for --filament-type (default: 1)",
    )

    # profiles subcommand
    profiles_cmd = sub.add_parser("profiles", parents=[common], help="List or pin slicer profiles")
    profiles_sub = profiles_cmd.add_subparsers(dest="profiles_command")

    list_cmd = profiles_sub.add_parser("list", help="List available profiles")
    list_cmd.add_argument(
        "--engine",
        default="orca",
        choices=["orca", "bambu"],
        help="Slicer engine (default: orca)",
    )
    list_cmd.add_argument(
        "--category",
        default=None,
        choices=["machine", "process", "filament"],
        help="Filter by category",
    )

    pin_cmd = profiles_sub.add_parser(
        "pin", help="Pin profiles from config into local profiles/ dir"
    )
    pin_cmd.add_argument("config", type=Path, help="Path to fabprint.toml")

    # status subcommand
    status_cmd = sub.add_parser("status", parents=[common], help="Query live printer status")
    status_cmd.add_argument("config", type=Path, help="Path to fabprint.toml")

    args = parser.parse_args(argv)

    if args.command is None:
        parser.print_help()
        sys.exit(1)

    logging.basicConfig(
        level=logging.DEBUG if getattr(args, "verbose", False) else logging.INFO,
        format="%(levelname)s: %(message)s",
    )

    if args.command == "plate":
        _cmd_plate(args)
    elif args.command == "slice":
        _cmd_slice(args)
    elif args.command == "print":
        _cmd_print(args)
    elif args.command == "status":
        _cmd_status(args)
    elif args.command == "profiles":
        _cmd_profiles(args)


def _generate_plate(
    args: argparse.Namespace, output: Path
) -> tuple[FabprintConfig, list[int], bool]:
    """Shared logic: load config, orient, arrange, optionally view, export 3MF.

    Returns (config, filament_ids, has_paint_colors).
    """
    cfg = load_config(args.config)

    meshes = []
    names = []
    filament_ids = []
    has_paint_colors = False
    part_info = []  # (name, copies, filament, scale, w, d, h) per unique part
    global_scale = getattr(args, "scale", None)
    for part in cfg.parts:
        base_mesh = load_mesh(part.file)
        oriented = orient_mesh(base_mesh, part.orient, part.rotate)
        scale = part.scale * global_scale if global_scale else part.scale
        if scale != 1.0:
            oriented.apply_scale(scale)
        # Store paint data in metadata (survives copy/transform)
        oriented.metadata["filament_id"] = part.filament
        paint_colors = extract_paint_colors(part.file)
        if paint_colors:
            oriented.metadata["paint_colors"] = paint_colors
            has_paint_colors = True
        w, d, h = oriented.extents
        part_info.append((part.file.stem, part.copies, part.filament, scale, w, d, h))
        for i in range(part.copies):
            meshes.append(oriented.copy())
            suffix = f"_{i + 1}" if part.copies > 1 else ""
            names.append(f"{part.file.stem}{suffix}")
            filament_ids.append(part.filament)

    _print_summary(part_info, len(meshes), cfg.plate.size)

    placements = arrange(meshes, names, cfg.plate.size, cfg.plate.padding)

    if getattr(args, "view", False):
        from fabprint.viewer import show_plate

        show_plate([p.mesh for p in placements], [p.name for p in placements], cfg.plate.size)

    scene = build_plate(placements, cfg.plate.size)
    export_plate(scene, output)
    return cfg, filament_ids, has_paint_colors


def _print_summary(
    part_info: list[tuple],
    total: int,
    plate_size: tuple[float, float],
) -> None:
    """Print a build summary table."""
    name_width = max(len(name) for name, *_ in part_info)
    print("\nParts:")
    for name, copies, filament, scale, w, d, h in part_info:
        scale_str = f"  {scale}x" if scale != 1.0 else ""
        print(
            f"  {name:<{name_width}}  x{copies}  slot {filament}"
            f"{scale_str}  {w:.0f}x{d:.0f}x{h:.0f}mm"
        )
    print(f"\nPlate: {total} parts on {plate_size[0]:.0f}x{plate_size[1]:.0f}mm")


def _cmd_plate(args: argparse.Namespace) -> None:
    output = args.output or Path("plate.3mf")
    _generate_plate(args, output)
    print(f"Plate exported to {output}")


def _do_slice(args: argparse.Namespace) -> Path:
    """Arrange, export, and slice. Returns the output directory."""
    from fabprint.slicer import parse_gcode_stats, slice_plate

    plate_3mf = Path("plate.3mf")
    cfg, filament_ids, has_paint_colors = _generate_plate(args, plate_3mf)
    print(f"Plate exported to {plate_3mf}")

    # CLI --filament-type overrides the config's filament list with a single
    # filament in the specified AMS slot.
    if args.filament_type:
        filaments = [args.filament_type]
        # Override all parts to use the specified slot
        filament_ids = [args.filament_slot] * len(filament_ids)
    elif has_paint_colors:
        # OrcaSlicer 2.3.1 CLI segfaults on paint_color + --load-filaments.
        # Skip filament profiles when paint_color data is present — the
        # paint_color attributes already encode which extruder to use.
        filaments = None
    else:
        filaments = cfg.slicer.filaments

    output_dir = slice_plate(
        input_3mf=plate_3mf,
        engine=cfg.slicer.engine,
        output_dir=args.output_dir,
        printer=cfg.slicer.printer,
        process=cfg.slicer.process,
        filaments=filaments,
        filament_ids=filament_ids,
        overrides=cfg.slicer.overrides or None,
        project_dir=cfg.base_dir,
        docker=args.docker or args.docker_version is not None,
        docker_version=args.docker_version,
        required_version=cfg.slicer.version,
    )
    print(f"Sliced gcode in {output_dir}")

    stats = parse_gcode_stats(output_dir)
    parts = []
    if "filament_g" in stats:
        parts.append(f"{stats['filament_g']:.1f}g filament")
    elif "filament_cm3" in stats:
        parts.append(f"{stats['filament_cm3']:.1f}cm3 filament")
    if "print_time" in stats:
        parts.append(f"estimated {stats['print_time']}")
    if parts:
        print(f"  {', '.join(parts)}")

    return output_dir


def _cmd_slice(args: argparse.Namespace) -> None:
    _do_slice(args)


def _cmd_print(args: argparse.Namespace) -> None:
    from fabprint.printer import send_print

    cfg = load_config(args.config)

    if cfg.printer is None:
        raise ValueError("No [printer] section in config. Required for printing.")

    if args.gcode:
        # Send pre-sliced gcode directly
        gcode_path = args.gcode.resolve()
        if not gcode_path.exists():
            raise FileNotFoundError(f"Gcode file not found: {gcode_path}")
    else:
        # Full pipeline: arrange → slice → find gcode
        output_dir = _do_slice(args)
        gcode_files = list(output_dir.glob("*.gcode"))
        if not gcode_files:
            raise RuntimeError(f"No gcode files found in {output_dir}")
        gcode_path = gcode_files[0]

    send_print(
        gcode_path,
        cfg.printer,
        dry_run=args.dry_run,
        upload_only=args.upload_only,
        experimental=getattr(args, "experimental", False),
    )


def _cmd_status(args: argparse.Namespace) -> None:
    from fabprint.cloud import parse_ams_trays
    from fabprint.printer import get_printer_status

    cfg = load_config(args.config)
    if cfg.printer is None:
        raise ValueError("No [printer] section in config.")

    serial = cfg.printer.serial or os.environ.get("BAMBU_SERIAL")
    if not serial:
        raise ValueError("No serial in [printer] config or BAMBU_SERIAL env var.")

    print(f"Querying printer {serial}...")
    status = get_printer_status(serial)

    state = status.get("gcode_state", "unknown")
    percent = status.get("mc_percent", 0)
    layer = status.get("layer_num", 0)
    total_layers = status.get("total_layer_num", 0)
    remaining = status.get("mc_remaining_time", 0)

    print(f"  State:    {state}")
    if state not in ("IDLE", "FINISH", "FAILED", ""):
        print(f"  Progress: {percent}%", end="")
        if total_layers:
            print(f" (layer {layer}/{total_layers})", end="")
        print()
        if remaining:
            h, m = divmod(int(remaining), 60)
            print(f"  Remaining: {h}h {m}m" if h else f"  Remaining: {m}m")

    ams_trays = parse_ams_trays(status)
    if ams_trays:
        tray_now_raw = int(status.get("ams", {}).get("tray_now", 255))
        print("  AMS:")
        for t in ams_trays:
            active = " <-- printing" if t["phys_slot"] == tray_now_raw else ""
            print(f"    slot {t['phys_slot'] + 1}  {t['type']:<12}  #{t['color']}{active}")


def _cmd_profiles(args: argparse.Namespace) -> None:
    from fabprint.profiles import CATEGORIES, discover_profiles, pin_profiles

    if args.profiles_command == "list":
        profiles = discover_profiles(args.engine)
        categories = [args.category] if args.category else list(CATEGORIES)
        for cat in categories:
            names = profiles.get(cat, {})
            print(f"\n{cat} ({len(names)} profiles):")
            for name in names:
                print(f"  {name}")

    elif args.profiles_command == "pin":
        cfg = load_config(args.config)
        pinned = pin_profiles(
            engine=cfg.slicer.engine,
            printer=cfg.slicer.printer,
            process=cfg.slicer.process,
            filaments=cfg.slicer.filaments,
            project_dir=cfg.base_dir,
        )
        print(f"Pinned {len(pinned)} profile(s)")
        for p in pinned:
            print(f"  {p}")

    else:
        print("Usage: fabprint profiles {list|pin}")
        sys.exit(1)
