package main

import (
	"bufio"
	"fmt"
	"image"
	_ "image/jpeg"
	_ "image/png"
	"math"
	"os"
	"os/exec"
	"strconv"
	"strings"
	"time"

	"github.com/antitbone/dwarfctl/internal/api"
	"github.com/antitbone/dwarfctl/internal/daemon"
	"github.com/antitbone/dwarfctl/internal/odometry"
	pb "github.com/antitbone/dwarfctl/proto"
	"github.com/spf13/cobra"
	"google.golang.org/protobuf/proto"
)

var (
	flagIP       string
	flagClientID string
	flagDebug    bool
	flagDaemon   string // daemon addr, e.g. "127.0.0.1:7777"
)

func main() {
	rootCmd := &cobra.Command{
		Use:   "dwarfctl",
		Short: "DWARF telescope control CLI",
		Long:  "Open-source client for DWARF II / DWARFLAB smart telescopes.\nControls cameras, motors, astrophotography, focus, tracking, and power\nvia the telescope's WebSocket API (port 9900).",
	}
	rootCmd.PersistentFlags().StringVarP(&flagIP, "ip", "i", "", "telescope IP address (required)")
	rootCmd.PersistentFlags().StringVar(&flagClientID, "client-id", "dwarfctl", "WebSocket client_id")
	rootCmd.PersistentFlags().BoolVarP(&flagDebug, "debug", "d", false, "verbose WebSocket traffic log")
	rootCmd.PersistentFlags().StringVar(&flagDaemon, "daemon", "", "daemon addr (e.g. 127.0.0.1:7777)")

	rootCmd.AddCommand(
		cmdServe(),
		cmdHealth(),
		cmdState(),
		cmdCamera(),
		cmdMotor(),
		cmdAstro(),
		cmdFocus(),
		cmdTrack(),
		cmdSystem(),
		cmdPower(),
		cmdMonitor(),
		cmdInteractive(),
		cmdPano(),
		cmdPreview(),
		cmdOrient(),
	)

	// --ip is required only when not using --daemon
	rootCmd.PersistentPreRun = func(cmd *cobra.Command, _ []string) {
		if flagDaemon == "" && flagIP == "" && cmd.Name() != "serve" && cmd.Name() != "help" && cmd.Name() != "completion" {
			die("--ip is required (or use --daemon to connect to a running daemon)")
		}
	}
	if err := rootCmd.Execute(); err != nil {
		os.Exit(1)
	}
}

// dial connects to the telescope and returns a ready Telescope + cleanup.
func dial() (*api.Telescope, func()) {
	scope := api.New(flagClientID)
	scope.SetDebug(flagDebug)
	if err := scope.Connect(flagIP); err != nil {
		fmt.Fprintf(os.Stderr, "Error: connect to %s: %v\n", flagIP, err)
		os.Exit(1)
	}
	return scope, func() { scope.Close() }
}

func mustCam(s string) api.Camera {
	switch strings.ToLower(s) {
	case "tele", "t", "0":
		return api.CameraTele
	case "wide", "w", "1":
		return api.CameraWide
	default:
		fmt.Fprintf(os.Stderr, "Error: invalid camera '%s' (use: tele|wide)\n", s)
		os.Exit(1)
		return api.CameraTele
	}
}

// --- health ---

func cmdHealth() *cobra.Command {
	return &cobra.Command{
		Use:   "health",
		Short: "Full telescope health check (connectivity + device state)",
		Run: func(_ *cobra.Command, _ []string) {
			checks := 0
			passed := 0

			check := func(name string, ok bool, detail string) {
				checks++
				if ok {
					passed++
					fmt.Printf("  ✅ %-20s %s\n", name, detail)
				} else {
					fmt.Printf("  ❌ %-20s %s\n", name, detail)
				}
			}

			fmt.Println("DWARF Telescope Health Check")
			fmt.Println(strings.Repeat("=", 50))

			// 1. Connect
			scope := api.New(flagClientID)
			scope.SetDebug(flagDebug)
			connectStart := time.Now()
			err := scope.Connect(flagIP)
			elapsed := time.Since(connectStart)
			check("WebSocket :9900", err == nil, fmt.Sprintf("%s (%dms)", flagIP+":9900", elapsed.Milliseconds()))
			if err != nil {
				fmt.Println("\nCannot connect. Aborting.")
				os.Exit(1)
			}
			defer scope.Close()

			// 2. Device state
			state, err := scope.GetDeviceState()
			check("Device state query", err == nil, fmt.Sprintf("%d bytes", len(state.String())))

			if err == nil {
				di := state.GetDeviceStateInfo()

				// 3. Battery
				batt := di.GetBatteryInfo().GetPercentage()
				check("Battery", batt > 0, fmt.Sprintf("%d%%", batt))

				// 4. Storage
				stor := di.GetStorageInfo()
				avail := stor.GetAvailableSize()
				total := stor.GetTotalSize()
				storOK := total > 0 && avail > 0 && stor.GetIsValid()
				check("Storage", storOK, fmt.Sprintf("%d/%d GB (%s)", avail, total, func() string {
					if storOK { return "valid" }
					return "INVALID"
				}()))

				// 5. RGB state
				rgb := di.GetRgbState().GetState()
				check("RGB ring", true, fmt.Sprintf("state=%d", rgb))

				// 6. Focus position
				focus := state.GetFocusMotorStateInfo().GetFocusPosition().GetPos()
				check("Focus motor", focus > 0, fmt.Sprintf("pos=%d", focus))

				// 7. Tele camera
				tele := state.GetTeleCameraStateInfo()
				teleRes := fmt.Sprintf("%dx%d", tele.GetResolutionWidth(), tele.GetResolutionHeight())
				check("Tele camera", tele.GetResolutionWidth() > 0, teleRes)

				// 8. Wide camera
				wide := state.GetWideCameraStateInfo()
				wideRes := fmt.Sprintf("%dx%d", wide.GetResolutionWidth(), wide.GetResolutionHeight())
				check("Wide camera", wide.GetResolutionWidth() > 0, wideRes)

				// 9. Motor position (may be NEED_RESET)
				pos, posErr := scope.MotorGetPosition(0)
				if posErr != nil {
					check("Motor position", false, posErr.Error())
				} else if pos.GetCode() != 0 {
					check("Motor position", false, fmt.Sprintf("code=%d (NEED_RESET)", pos.GetCode()))
				} else {
					check("Motor position", true, fmt.Sprintf("%.2f", pos.GetPosition()))
				}

				// 10. Temperature
				cmosTemp := state.GetWideCameraStateInfo().GetCmosTemperature().GetTemperature()
				check("CMOS temp", cmosTemp > -270, fmt.Sprintf("%d°C", cmosTemp))
			}

			fmt.Println(strings.Repeat("=", 50))
			status := "HEALTHY"
			if passed < checks {
				status = "ISSUES DETECTED"
			}
			fmt.Printf("Result: %d/%d checks passed — %s\n", passed, checks, status)
		},
	}
}

// --- state ---

func cmdState() *cobra.Command {
	return &cobra.Command{
		Use:   "state",
		Short: "Get device state info",
		Run: func(cmd *cobra.Command, args []string) {
			scope, cleanup := dial()
			defer cleanup()
			state, err := scope.GetDeviceState()
			if err != nil {
				die("get state: %v", err)
			}
			fmt.Printf("Device State:\n")
			printProto(state)
		},
	}
}

// --- camera ---

func cmdCamera() *cobra.Command {
	c := &cobra.Command{
		Use:   "camera",
		Short: "Camera control (open/close/photo/params)",
	}
	c.AddCommand(
		&cobra.Command{
			Use:   "open [--cam tele|wide]",
			Short: "Open camera",
			Run: func(cmd *cobra.Command, _ []string) {
				cam := mustCam(flagCam(cmd))
				scope, cleanup := dial()
				defer cleanup()
				must(scope.OpenCamera(cam))
				fmt.Println("camera opened")
			},
		},
		&cobra.Command{
			Use:   "close [--cam tele|wide]",
			Short: "Close camera",
			Run: func(cmd *cobra.Command, _ []string) {
				cam := mustCam(flagCam(cmd))
				scope, cleanup := dial()
				defer cleanup()
				must(scope.CloseCamera(cam))
				fmt.Println("camera closed")
			},
		},
		&cobra.Command{
			Use:   "photo [--cam tele|wide] [--raw]",
			Short: "Take a photo",
			Run: func(cmd *cobra.Command, _ []string) {
				cam := mustCam(flagCam(cmd))
				raw, _ := cmd.Flags().GetBool("raw")
				scope, cleanup := dial()
				defer cleanup()
				if raw {
					must(scope.TakeRawPhoto(cam))
				} else {
					must(scope.TakePhoto(cam))
				}
				fmt.Println("photo captured")
			},
		},
		&cobra.Command{
			Use:   "burst [--cam tele|wide] [start|stop]",
			Short: "Start or stop burst capture",
			Args:  cobra.ExactArgs(1),
			Run: func(cmd *cobra.Command, args []string) {
				cam := mustCam(flagCam(cmd))
				scope, cleanup := dial()
				defer cleanup()
				switch args[0] {
				case "start":
					must(scope.StartBurst(cam))
				case "stop":
					must(scope.StopBurst(cam))
				default:
					die("usage: camera burst [start|stop]")
				}
			},
		},
		&cobra.Command{
			Use:   "record [--cam tele|wide] [start|stop]",
			Short: "Start or stop video recording",
			Args:  cobra.ExactArgs(1),
			Run: func(cmd *cobra.Command, args []string) {
				cam := mustCam(flagCam(cmd))
				scope, cleanup := dial()
				defer cleanup()
				switch args[0] {
				case "start":
					must(scope.StartRecord(cam))
				case "stop":
					must(scope.StopRecord(cam))
				default:
					die("usage: camera record [start|stop]")
				}
			},
		},
		&cobra.Command{
			Use:   "exp [--cam tele|wide] <index>",
			Short: "Set exposure index (0=1/10000s, see params_range.json)",
			Args:  cobra.ExactArgs(1),
			Run: func(cmd *cobra.Command, args []string) {
				cam := mustCam(flagCam(cmd))
				idx := parseInt32(args[0])
				scope, cleanup := dial()
				defer cleanup()
				must(scope.SetExposure(cam, idx))
				fmt.Printf("exposure set to %d\n", idx)
			},
		},
		&cobra.Command{
			Use:   "gain [--cam tele|wide] <index>",
			Short: "Set gain index (0-200+)",
			Args:  cobra.ExactArgs(1),
			Run: func(cmd *cobra.Command, args []string) {
				cam := mustCam(flagCam(cmd))
				idx := parseInt32(args[0])
				scope, cleanup := dial()
				defer cleanup()
				must(scope.SetGain(cam, idx))
				fmt.Printf("gain set to %d\n", idx)
			},
		},
		&cobra.Command{
			Use:   "params [--cam tele|wide]",
			Short: "Get all camera parameters",
			Run: func(cmd *cobra.Command, _ []string) {
				cam := mustCam(flagCam(cmd))
				scope, cleanup := dial()
				defer cleanup()
				params, err := scope.GetAllParams(cam)
				must(err)
				printProto(params)
			},
		},
	)
	// Add --cam to all subcommands, --raw to photo only
	for _, sub := range c.Commands() {
		sub.Flags().String("cam", "tele", "camera: tele|wide")
		if sub.Name() == "photo" {
			sub.Flags().Bool("raw", false, "capture RAW instead of JPEG")
		}
	}
	return c
}

// --- motor ---

func cmdMotor() *cobra.Command {
	c := &cobra.Command{Use: "motor", Short: "Motor control (slew/joystick)"}
	c.AddCommand(
		&cobra.Command{
			Use:   "slew <angle-deg> <length>",
			Short: "Slew via joystick vector (angle 0-360, length 0-1)",
			Args:  cobra.ExactArgs(2),
			Run: func(_ *cobra.Command, args []string) {
				angle := parseFloat(args[0])
				length := parseFloat(args[1])
				scope, cleanup := dial()
				defer cleanup()
				must(scope.SlewJoystick(angle, length))
				fmt.Printf("slew: angle=%.1f length=%.2f\n", angle, length)
			},
		},
		&cobra.Command{
			Use:   "stop [motor-id]",
			Short: "Stop motors (id 0=RA, 1=DEC, default=both)",
			Run: func(cmd *cobra.Command, args []string) {
				id := int32(0)
				if len(args) > 0 {
					id = parseInt32(args[0])
				}
				scope, cleanup := dial()
				defer cleanup()
				must(scope.MotorStop(id))
				fmt.Println("motor stopped")
			},
		},
		&cobra.Command{
			Use:   "goto <motor-id> <position> <speed>",
			Short: "Move motor to position at speed",
			Args:  cobra.ExactArgs(3),
			Run: func(_ *cobra.Command, args []string) {
				id := parseInt32(args[0])
				pos := parseFloat(args[1])
				speed := parseFloat(args[2])
				scope, cleanup := dial()
				defer cleanup()
				must(scope.MotorRunTo(id, pos, speed))
				fmt.Printf("motor %d -> pos %.2f @ speed %.2f\n", id, pos, speed)
			},
		},
		&cobra.Command{
			Use:   "reset <motor-id> [direction]",
			Short: "Reset motor to home (DISABLED — causes reboot)",
			Args:  cobra.RangeArgs(1, 2),
			Run: func(_ *cobra.Command, args []string) {
				id := parseInt32(args[0])
				dir := false
				if len(args) > 1 && args[1] == "1" {
					dir = true
				}
				scope, cleanup := dial()
				defer cleanup()
				must(scope.MotorReset(id, dir))
			},
		},
		&cobra.Command{
			Use:   "position <motor-id>",
			Short: "Get motor position (experimental cmd 14001)",
			Args:  cobra.ExactArgs(1),
			Run: func(_ *cobra.Command, args []string) {
				id := parseInt32(args[0])
				scope, cleanup := dial()
				defer cleanup()
				pos, err := scope.MotorGetPosition(id)
				must(err)
				fmt.Printf("motor %d: id=%d code=%d position=%.2f\n", id, pos.GetId(), pos.GetCode(), pos.GetPosition())
			},
		},
		&cobra.Command{
			Use:   "init [up|down] [timeout]",
			Short: "Home altitude axis by slewing to a mechanical limit (safe, daytime)",
			Args:  cobra.MaximumNArgs(2),
			Run: func(_ *cobra.Command, args []string) {
				dir := 90.0 // default: up
				if len(args) > 0 && args[0] == "down" {
					dir = 270.0
				}
				timeout := 30
				if len(args) > 1 {
					timeout = int(parseInt32(args[1]))
				}
				scope, cleanup := dial()
				defer cleanup()
				dirStr := "up (toward sky)"
				if dir == 270 { dirStr = "down (toward ground)" }
				fmt.Printf("Initializing: slewing %s at 0.5 until limit (max %ds)...\n", dirStr, timeout)
				must(scope.MotorInit(dir, timeout))
				fmt.Println("Init complete.")
			},
		},
	)
	return c
}

// --- astro ---

func cmdAstro() *cobra.Command {
	c := &cobra.Command{Use: "astro", Short: "Astrophotography (calibrate/goto/stack)"}
	c.AddCommand(
		&cobra.Command{
			Use:   "calibrate [start|stop]",
			Short: "Start or stop star calibration (requires starry sky + location)",
			Args:  cobra.ExactArgs(1),
			Run: func(_ *cobra.Command, args []string) {
				scope, cleanup := dial()
				defer cleanup()
				switch args[0] {
				case "start":
					must(scope.StartCalibrationWithLocation(2.3522, 48.8566))
					fmt.Println("calibration started (lon=2.35 lat=48.85)")
				case "stop":
					must(scope.StopCalibration())
					fmt.Println("calibration stopped")
				default:
					die("usage: astro calibrate [start|stop]")
				}
			},
		},
		&cobra.Command{
			Use:   "goto-dso <ra-deg> <dec-deg> [name]",
			Short: "GoTo a deep-sky object by RA/Dec (degrees)",
			Args:  cobra.MinimumNArgs(2),
			Run: func(_ *cobra.Command, args []string) {
				ra := parseFloat(args[0])
				dec := parseFloat(args[1])
				name := ""
				if len(args) > 2 {
					name = strings.Join(args[2:], " ")
				}
				scope, cleanup := dial()
				defer cleanup()
				must(scope.GotoDSO(ra, dec, name))
				fmt.Printf("GoTo DSO: RA=%.4f° Dec=%.4f° (%s)\n", ra, dec, name)
			},
		},
		&cobra.Command{
			Use:   "goto-solar <index>",
			Short: "GoTo solar system object (1=Mercury..7=Neptune, see planet table)",
			Args:  cobra.ExactArgs(1),
			Run: func(_ *cobra.Command, args []string) {
				idx := parseInt32(args[0])
				scope, cleanup := dial()
				defer cleanup()
				must(scope.GotoSolar(idx))
				fmt.Printf("GoTo solar system object %d\n", idx)
			},
		},
		&cobra.Command{
			Use:   "goto-stop",
			Short: "Abort GoTo",
			Run: func(_ *cobra.Command, _ []string) {
				scope, cleanup := dial()
				defer cleanup()
				must(scope.StopGoto())
				fmt.Println("GoTo stopped")
			},
		},
		&cobra.Command{
			Use:   "stack [start|stop]",
			Short: "Start or stop live stacking",
			Args:  cobra.ExactArgs(1),
			Run: func(_ *cobra.Command, args []string) {
				scope, cleanup := dial()
				defer cleanup()
				switch args[0] {
				case "start":
					must(scope.StartLiveStacking())
				case "stop":
					must(scope.StopLiveStacking())
				default:
					die("usage: astro stack [start|stop]")
				}
			},
		},
		&cobra.Command{
			Use:   "eq-solve [start|stop]",
			Short: "Start or stop EQ solving (polar alignment)",
			Args:  cobra.ExactArgs(1),
			Run: func(_ *cobra.Command, args []string) {
				scope, cleanup := dial()
				defer cleanup()
				switch args[0] {
				case "start":
					must(scope.StartEqSolving())
				case "stop":
					must(scope.StopEqSolving())
				default:
					die("usage: astro eq-solve [start|stop]")
				}
			},
		},
	)
	return c
}

// --- focus ---

func cmdFocus() *cobra.Command {
	c := &cobra.Command{Use: "focus", Short: "Focus control"}
	c.AddCommand(
		&cobra.Command{
			Use:   "auto",
			Short: "Trigger autofocus",
			Run: func(_ *cobra.Command, _ []string) {
				scope, cleanup := dial()
				defer cleanup()
				must(scope.AutoFocus())
				fmt.Println("autofocus triggered")
			},
		},
		&cobra.Command{
			Use:   "step <0|1>",
			Short: "Manual single-step focus (0=in, 1=out)",
			Args:  cobra.ExactArgs(1),
			Run: func(_ *cobra.Command, args []string) {
				dir := uint32(parseInt32(args[0]))
				scope, cleanup := dial()
				defer cleanup()
				must(scope.ManualSingleStepFocus(dir))
			},
		},
		&cobra.Command{
			Use:   "astro-af [start|stop]",
			Short: "Start or stop astro autofocus",
			Args:  cobra.ExactArgs(1),
			Run: func(_ *cobra.Command, args []string) {
				scope, cleanup := dial()
				defer cleanup()
				switch args[0] {
				case "start":
					must(scope.StartAstroAutoFocus())
				case "stop":
					must(scope.StopAstroAutoFocus())
				}
			},
		},
	)
	return c
}

// --- track ---

func cmdTrack() *cobra.Command {
	c := &cobra.Command{Use: "track", Short: "Tracking control"}
	c.AddCommand(
		&cobra.Command{
			Use:   "start <x> <y> <w> <h> [cam-id]",
			Short: "Start tracking an object in a region",
			Args:  cobra.MinimumNArgs(4),
			Run: func(_ *cobra.Command, args []string) {
				x, y, w, h := parseInt32(args[0]), parseInt32(args[1]), parseInt32(args[2]), parseInt32(args[3])
				cam := int32(0)
				if len(args) > 4 {
					cam = parseInt32(args[4])
				}
				scope, cleanup := dial()
				defer cleanup()
				must(scope.StartTracking(x, y, w, h, cam))
				fmt.Printf("tracking started: bbox(%d,%d,%d,%d) cam=%d\n", x, y, w, h, cam)
			},
		},
		&cobra.Command{
			Use:   "stop",
			Short: "Stop tracking",
			Run: func(_ *cobra.Command, _ []string) {
				scope, cleanup := dial()
				defer cleanup()
				must(scope.StopTracking())
				fmt.Println("tracking stopped")
			},
		},
	)
	return c
}

// --- system ---

func cmdSystem() *cobra.Command {
	c := &cobra.Command{Use: "system", Short: "System commands (time/location)"}
	c.AddCommand(
		&cobra.Command{
			Use:   "sync-time",
			Short: "Sync telescope clock to this machine",
			Run: func(_ *cobra.Command, _ []string) {
				scope, cleanup := dial()
				defer cleanup()
				_, tz := time.Now().Zone()
				must(scope.SetTime(uint64(time.Now().Unix()), float64(tz)))
				fmt.Println("time synced")
			},
		},
		&cobra.Command{
			Use:   "set-location <lat> <lon> [alt]",
			Short: "Set observing location",
			Args:  cobra.MinimumNArgs(2),
			Run: func(_ *cobra.Command, args []string) {
				lat := parseFloat(args[0])
				lon := parseFloat(args[1])
				alt := 0.0
				if len(args) > 2 {
					alt = parseFloat(args[2])
				}
				scope, cleanup := dial()
				defer cleanup()
				must(scope.SetLocation(lat, lon, alt))
				fmt.Printf("location set: %.4f, %.4f, %.0fm\n", lat, lon, alt)
			},
		},
	)
	return c
}

// --- power ---

func cmdPower() *cobra.Command {
	c := &cobra.Command{Use: "power", Short: "Power & RGB control"}
	c.AddCommand(
		&cobra.Command{Use: "rgb-on", Short: "Turn on RGB ring light", Run: func(_ *cobra.Command, _ []string) {
			scope, cleanup := dial(); defer cleanup(); must(scope.RGBOn()); fmt.Println("RGB on")
		}},
		&cobra.Command{Use: "rgb-off", Short: "Turn off RGB ring light", Run: func(_ *cobra.Command, _ []string) {
			scope, cleanup := dial(); defer cleanup(); must(scope.RGBOff()); fmt.Println("RGB off")
		}},
		&cobra.Command{Use: "off", Short: "Power down telescope", Run: func(_ *cobra.Command, _ []string) {
			scope, cleanup := dial(); defer cleanup(); must(scope.PowerDown()); fmt.Println("powering down")
		}},
		&cobra.Command{Use: "reboot", Short: "Reboot telescope", Run: func(_ *cobra.Command, _ []string) {
			scope, cleanup := dial(); defer cleanup(); must(scope.Reboot()); fmt.Println("rebooting")
		}},
	)
	return c
}

// --- preview ---

func cmdPreview() *cobra.Command {
	c := &cobra.Command{Use: "preview", Short: "RTSP preview frame capture"}
	c.AddCommand(
		&cobra.Command{
			Use:   "grab [--cam tele|wide] [output.jpg]",
			Short: "Capture one frame from RTSP (opens camera automatically)",
			Args:  cobra.MaximumNArgs(1),
			Run: func(cmd *cobra.Command, args []string) {
				cam := mustCam(flagCam(cmd))
				ch := "ch0"
				if cam == api.CameraWide {
					ch = "ch1"
				}
				out := "/tmp/preview.jpg"
				if len(args) > 0 {
					out = args[0]
				}
				scope, cleanup := dial()
				defer cleanup()
				must(scope.OpenCamera(cam))
				time.Sleep(2 * time.Second)
				url := fmt.Sprintf("rtsp://%s:554/%s/stream0", flagIP, ch)
				ffmpegCmd := exec.Command("ffmpeg",
					"-rtsp_transport", "tcp",
					"-fflags", "nobuffer",
					"-flags", "low_delay",
					"-probesize", "4096",
					"-analyzeduration", "1000000",
					"-i", url, "-frames:v", "1", "-q:v", "2", "-y", out)
				if err := ffmpegCmd.Run(); err != nil {
					die("ffmpeg: %v (is ffmpeg installed?)", err)
				}
				fmt.Printf("frame saved to %s\n", out)
			},
		},
	)
	for _, sub := range c.Commands() {
		sub.Flags().String("cam", "tele", "camera: tele|wide")
	}
	return c
}

// --- panorama ---

func cmdPano() *cobra.Command {
	c := &cobra.Command{Use: "pano", Short: "Panorama control"}
	c.AddCommand(
		&cobra.Command{
			Use:   "start",
			Short: "Start panorama grid scan (homes motors first)",
			Run: func(_ *cobra.Command, _ []string) {
				scope, cleanup := dial()
				defer cleanup()
				must(scope.StartPanoramaGrid())
				fmt.Println("panorama grid started (motors homing)")
			},
		},
		&cobra.Command{
			Use:   "stop",
			Short: "Stop panorama",
			Run: func(_ *cobra.Command, _ []string) {
				scope, cleanup := dial()
				defer cleanup()
				must(scope.StopPanorama())
				fmt.Println("panorama stopped")
			},
		},
	)
	return c
}

// --- monitor ---

func cmdMonitor() *cobra.Command {
	return &cobra.Command{
		Use:   "monitor",
		Short: "Listen for telescope notifications (Ctrl-C to stop)",
		Run: func(_ *cobra.Command, _ []string) {
			scope, cleanup := dial()
			defer cleanup()
			ch := scope.Notifications()
			fmt.Println("Monitoring notifications (Ctrl-C to stop)...")
			for pkt := range ch {
				cmdID := pkt.GetCmd()
				extra := ""
				if cmdID == 15295 && len(pkt.GetData()) > 0 {
					att := &pb.DeviceAttitude{}
					if proto.Unmarshal(pkt.GetData(), att) == nil {
						extra = fmt.Sprintf(" pitch=%.1f yaw=%.1f roll=%.1f", att.GetPitch(), att.GetYaw(), att.GetRoll())
					}
				}
				fmt.Printf("[NOTIFY] cmd=%d module=%d type=%d data=%d%s\n",
					cmdID, pkt.GetModuleId(), pkt.GetType(), len(pkt.GetData()), extra)
			}
		},
	}
}

func cmdInteractive() *cobra.Command {
	return &cobra.Command{
		Use:   "interactive",
		Short: "Interactive slew with real-time attitude (Ctrl-C to exit)",
		Run: func(_ *cobra.Command, _ []string) {
			scope, cleanup := dial()
			defer cleanup()
			ch := scope.Notifications()
			go func() {
				for pkt := range ch {
					if pkt.GetCmd() == 15295 && len(pkt.GetData()) > 0 {
						att := &pb.DeviceAttitude{}
						if proto.Unmarshal(pkt.GetData(), att) == nil {
							fmt.Printf("\r  [ATTITUDE] pitch=%.1f yaw=%.1f roll=%.1f   \n", att.GetPitch(), att.GetYaw(), att.GetRoll())
						}
					}
				}
			}()
			fmt.Println("Interactive slew. Commands: <angle> <speed> [duration_s], stop, photo, state, q")
			scanner := bufio.NewScanner(os.Stdin)
			for {
				fmt.Print("> ")
				if !scanner.Scan() { break }
				line := strings.TrimSpace(scanner.Text())
				if line == "" { continue }
				parts := strings.Fields(line)
				switch parts[0] {
				case "q", "quit", "exit":
					return
				case "stop":
					must(scope.MotorJoystickStop())
					fmt.Println("stopped")
				case "photo":
					must(scope.TakePhoto(api.CameraTele))
					fmt.Println("photo taken")
				case "state":
					st, err := scope.GetDeviceState()
					must(err)
					fmt.Printf("battery=%d%% focus=%d\n", st.GetDeviceStateInfo().GetBatteryInfo().GetPercentage(), st.GetFocusMotorStateInfo().GetFocusPosition().GetPos())
				default:
					if len(parts) < 2 { fmt.Println("usage: <angle> <speed> [duration]"); continue }
					angle := parseFloat(parts[0])
					speed := parseFloat(parts[1])
					dur := 1.0
					if len(parts) > 2 { dur = parseFloat(parts[2]) }
					must(scope.SlewJoystick(angle, speed))
					time.Sleep(time.Duration(dur * float64(time.Second)))
					must(scope.SlewJoystick(0, 0))
					fmt.Println("done")
				}
			}
		},
	}
}

// --- orient (visual odometry) ---

func cmdOrient() *cobra.Command {
	c := &cobra.Command{
		Use:   "orient",
		Short: "Image-based orientation (visual odometry via wide-angle frames)",
		Long: "Estimate telescope pointing rotation by comparing wide-angle frames.\n" +
			"Uses FFT phase correlation — works on any scene (sky, horizon, clouds)\n" +
			"without star identification or plate solving.\n\n" +
			"Designed for a static alt-az mount tracking airplanes (day) or satellites (night).",
	}
	c.AddCommand(
		&cobra.Command{
			Use:   "status",
			Short: "Get current orientation from the daemon (requires 'serve' running)",
			Run: func(_ *cobra.Command, _ []string) {
				dc := daemonClient()
				if dc == nil {
					die("no daemon: use --daemon ADDR or start 'dwarfctl serve'")
				}
				o, err := dc.Orientation()
				must(err)
				fmt.Println(o)
			},
		},
		&cobra.Command{
			Use:   "reset",
			Short: "Reset the odometry tracker (requires daemon)",
			Run: func(_ *cobra.Command, _ []string) {
				dc := daemonClient()
				if dc == nil {
					die("no daemon: use --daemon ADDR or start 'dwarfctl serve'")
				}
				must(dc.Reset())
				fmt.Println("orientation reset")
			},
		},
		&cobra.Command{
			Use:   "grab [output.jpg]",
			Short: "Grab a frame via the daemon (camera stays open)",
			Args:  cobra.MaximumNArgs(1),
			Run: func(_ *cobra.Command, args []string) {
				dc := daemonClient()
				if dc == nil {
					die("no daemon: use --daemon ADDR or start 'dwarfctl serve'")
				}
				out := ""
				if len(args) > 0 {
					out = args[0]
				}
				path, err := dc.Grab(out)
				must(err)
				fmt.Printf("frame saved: %s\n", path)
			},
		},
		&cobra.Command{
			Use:   "compare <img1.jpg> <img2.jpg>",
			Short: "Measure rotation between two image files",
			Args:  cobra.ExactArgs(2),
			Run: func(cmd *cobra.Command, args []string) {
				fovH, _ := cmd.Flags().GetFloat64("fov-h")
				fovV, _ := cmd.Flags().GetFloat64("fov-v")
				size, _ := cmd.Flags().GetInt("size")
				r, err := odometry.EstimateFiles(args[0], args[1], fovH, fovV, size)
				must(err)
				fmt.Printf("Scene shift (img1 -> img2):\n  %s\n", r)
				fmt.Printf("  grid=%dx%d  fov=%.2f°x%.2f°\n", r.Size, r.Size, fovH, fovV)
				if r.Confidence < 0.05 {
					fmt.Println("  ⚠ low confidence (frames too similar or motion too large)")
				}
			},
		},
		&cobra.Command{
			Use:   "live",
			Short: "Continuously track pointing from RTSP frames (Ctrl-C to stop)",
			Run: func(cmd *cobra.Command, _ []string) {
				cam := mustCam(cmd.Flag("cam").Value.String())
				fovH, _ := cmd.Flags().GetFloat64("fov-h")
				fovV, _ := cmd.Flags().GetFloat64("fov-v")
				size, _ := cmd.Flags().GetInt("size")
				interval, _ := cmd.Flags().GetDuration("interval")

				ch := "ch0"
				if cam == api.CameraWide {
					ch = "ch1"
				}

				scope, cleanup := dial()
				defer cleanup()
				must(scope.OpenCamera(cam))
				time.Sleep(2 * time.Second)

				url := fmt.Sprintf("rtsp://%s:554/%s/stream0", flagIP, ch)
				tracker := odometry.NewTracker(fovH, fovV, size)
				tmpA := "/tmp/dwarf_orient_a.jpg"
				tmpB := "/tmp/dwarf_orient_b.jpg"
				useA := true

				fmt.Printf("Live visual odometry on %s (fov %.1f°x%.1f°, grid %d, every %s)\n",
					url, fovH, fovV, size, interval)
				fmt.Println("Ctrl-C to stop.")
				ticker := time.NewTicker(interval)
				defer ticker.Stop()

				grab := func(out string) error {
					return exec.Command("ffmpeg",
						"-rtsp_transport", "tcp",
						"-fflags", "nobuffer",
						"-flags", "low_delay",
						"-probesize", "4096",
						"-analyzeduration", "1000000",
						"-i", url, "-frames:v", "1", "-q:v", "2", "-y", out).Run()
				}

				// Prime with first frame.
				first := "/tmp/dwarf_orient_prime.jpg"
				if err := grab(first); err != nil {
					die("ffmpeg grab: %v (is ffmpeg installed?)", err)
				}
				img, err := decodeJPEG(first)
				if err != nil {
					die("decode: %v", err)
				}
				o, _ := tracker.Update(img)
				fmt.Printf("[prime] %s\n", o)

				for range ticker.C {
					out := tmpA
					if !useA {
						out = tmpB
					}
					useA = !useA
					if err := grab(out); err != nil {
						fmt.Fprintf(os.Stderr, "  grab error: %v\n", err)
						continue
					}
					img, err := decodeJPEG(out)
					if err != nil {
						fmt.Fprintf(os.Stderr, "  decode error: %v\n", err)
						continue
					}
					o, err := tracker.Update(img)
					if err != nil {
						fmt.Fprintf(os.Stderr, "  odometry error: %v\n", err)
						continue
					}
					ts := time.Now().Format("15:04:05")
					fmt.Printf("[%s] %s\n", ts, o)
				}
			},
		},
	)
	for _, sub := range c.Commands() {
		sub.Flags().Float64("fov-h", odometry.DefaultFoVH, "horizontal field of view (degrees)")
		sub.Flags().Float64("fov-v", odometry.DefaultFoVV, "vertical field of view (degrees)")
		sub.Flags().Int("size", odometry.DefaultSize, "FFT grid size (power of 2: 128/256/512)")
		if sub.Name() == "live" {
			sub.Flags().String("cam", "wide", "camera: tele|wide")
			sub.Flags().Duration("interval", 3*time.Second, "time between frames")
		}
	}
	return c
}

// decodeJPEG reads a JPEG/PNG file into an image.Image.
func decodeJPEG(path string) (image.Image, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	img, _, err := image.Decode(f)
	return img, err
}

// --- serve (daemon) ---

func cmdServe() *cobra.Command {
	c := &cobra.Command{
		Use:   "serve",
		Short: "Start persistent telescope daemon (keeps WS + camera + odometry alive)",
		Run: func(cmd *cobra.Command, _ []string) {
			addr, _ := cmd.Flags().GetString("addr")
			cam, _ := cmd.Flags().GetString("cam")
			fovH, _ := cmd.Flags().GetFloat64("fov-h")
			fovV, _ := cmd.Flags().GetFloat64("fov-v")
			size, _ := cmd.Flags().GetInt("size")
			interval, _ := cmd.Flags().GetDuration("interval")

			srv := daemon.New(daemon.Config{
				IP:       flagIP,
				Addr:     addr,
				FoVH:     fovH,
				FoVV:     fovV,
				GridSize: size,
				Camera:   cam,
				Interval: interval,
				Debug:    flagDebug,
			})
			if err := srv.Run(); err != nil {
				die("daemon: %v", err)
			}
		},
	}
	c.Flags().String("addr", daemon.DefaultAddr, "HTTP listen address")
	c.Flags().String("cam", "wide", "camera to open: tele|wide")
	c.Flags().Float64("fov-h", odometry.DefaultFoVH, "horizontal FoV (degrees)")
	c.Flags().Float64("fov-v", odometry.DefaultFoVV, "vertical FoV (degrees)")
	c.Flags().Int("size", odometry.DefaultSize, "FFT grid size")
	c.Flags().Duration("interval", 5*time.Second, "odometry frame interval")
	return c
}

// daemonClient returns a daemon client if --daemon is set, else nil.
func daemonClient() *daemon.Client {
	if flagDaemon == "" {
		return nil
	}
	return daemon.NewClient(flagDaemon)
}

// --- helpers ---

func flagCam(cmd *cobra.Command) string {
	s, _ := cmd.Flags().GetString("cam")
	return s
}

func parseInt32(s string) int32 {
	v, err := strconv.ParseInt(s, 0, 32)
	if err != nil {
		die("invalid number '%s': %v", s, err)
	}
	return int32(v)
}

func parseFloat(s string) float64 {
	v, err := strconv.ParseFloat(s, 64)
	if err != nil {
		die("invalid float '%s': %v", s, err)
	}
	if math.IsNaN(v) || math.IsInf(v, 0) {
		die("invalid float '%s'", s)
	}
	return v
}

func die(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...)
	os.Exit(1)
}

func must(err error) {
	if err != nil {
		die("%v", err)
	}
}

func printProto(v any) {
	fmt.Printf("  %+v\n", v)
}
