# target_simulator/gui/ppi_display.py

"""
A reusable Tkinter widget that displays a Plan Position Indicator (PPI)
using Matplotlib, capable of showing both live targets and trajectory previews,
and comparing simulated vs. real-time data.
"""

import tkinter as tk
from tkinter import ttk
import math
import time
import logging
import numpy as np
import collections
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import matplotlib as mpl
from typing import List, Dict

from target_simulator.core.models import Target, Waypoint, ManeuverType, NM_TO_FT

# Module-level logger
logger = logging.getLogger(__name__)


class PPIDisplay(ttk.Frame):
    """
    A custom widget for the PPI radar display.
    """

    TRAIL_LENGTH = 100

    def __init__(
        self,
        master,
        max_range_nm: int = 100,
        scan_limit_deg: int = 60,
        trail_length: int = None,
    ):
        super().__init__(master)
        self.max_range = max_range_nm
        self.scan_limit_deg = scan_limit_deg
        self.sim_target_artists: List[mpl.artist.Artist] = []
        self.real_target_artists: List[mpl.artist.Artist] = []
        self.sim_trail_artists: List[mpl.artist.Artist] = []
        self.real_trail_artists: List[mpl.artist.Artist] = []
        self.sim_label_artists: List[mpl.artist.Artist] = []
        self.real_label_artists: List[mpl.artist.Artist] = []
        self.trail_length = trail_length or self.TRAIL_LENGTH
        self._trails = {
            "simulated": collections.defaultdict(
                lambda: collections.deque(maxlen=self.trail_length)
            ),
            "real": collections.defaultdict(
                lambda: collections.deque(maxlen=self.trail_length)
            ),
        }
        self.preview_artists: List[mpl.artist.Artist] = []
        self.preview_path_artists: Dict[int, List[mpl.artist.Artist]] = {}

        # Display options
        self.show_sim_points_var = tk.BooleanVar(value=True)
        self.show_real_points_var = tk.BooleanVar(value=True)
        self.show_sim_trail_var = tk.BooleanVar(value=False)
        self.show_real_trail_var = tk.BooleanVar(value=False)
        self.animate_antenna_var = tk.BooleanVar(value=True)
        self.display_mode_var = tk.StringVar(value="North-Up")

        self.canvas = None
        self._ownship_artist: mpl.lines.Line2D | None = None
        self.ownship_heading_deg = 0.0

        self._create_controls()
        self._create_plot()

        self._real_update_timestamps = collections.deque(maxlen=10000)
        self._last_update_summary_time = time.monotonic()
        self._update_summary_interval_s = 1.0

        self._antenna_state = {
            "last_az_deg": None,
            "last_ts": None,
            "next_az_deg": None,
            "next_ts": None,
            "animating": False,
            "tick_ms": 33,
            # When no new antenna timestamps arrive, perform a small
            # continuous idle sweep so the UI remains visually active.
            # Degrees per second to rotate while idle (fallback).
            "idle_rotation_deg_per_s": 30.0,
        }
        self._antenna_line_artist: mpl.lines.Line2D | None = None

    def _on_display_options_changed(self, *args):
        self.clear_all_targets()
        if self.canvas:
            self.canvas.draw()

    def _on_display_mode_changed(self, *args):
        """Callback when the display mode (North-Up/Heading-Up) changes."""
        self._update_plot_orientation()
        if self.canvas:
            self.canvas.draw_idle()

    def _create_controls(self):
        """Creates the organized 4-section control panel."""
        top_frame = ttk.Frame(self)
        top_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(5, 2))

        # Section 1: Radar Controls
        radar_frame = ttk.LabelFrame(top_frame, text="Radar", padding=5)
        radar_frame.pack(side=tk.LEFT, padx=(0, 5), fill=tk.Y)

        range_subframe = ttk.Frame(radar_frame)
        range_subframe.pack(anchor="w")
        ttk.Label(range_subframe, text="Range (NM):").pack(side=tk.LEFT)
        all_steps = [10, 20, 40, 80, 100, 160, 240, 320]
        valid_steps = sorted(
            [s for s in all_steps if s <= self.max_range]
            + ([self.max_range] if self.max_range not in all_steps else [])
        )
        self.range_var = tk.IntVar(value=self.max_range)
        self.range_selector = ttk.Combobox(
            range_subframe,
            textvariable=self.range_var,
            values=valid_steps,
            state="readonly",
            width=5,
        )
        self.range_selector.pack(side=tk.LEFT, padx=5)

        ttk.Checkbutton(
            radar_frame,
            text="Animate Antenna",
            variable=self.animate_antenna_var,
            command=self._on_antenna_animate_changed,
        ).pack(anchor="w", pady=(4, 0))

        # Section 2: Display Mode
        mode_frame = ttk.LabelFrame(top_frame, text="Display Mode", padding=5)
        mode_frame.pack(side=tk.LEFT, padx=5, fill=tk.Y)
        ttk.Radiobutton(
            mode_frame,
            text="North-Up",
            variable=self.display_mode_var,
            value="North-Up",
            command=self._on_display_mode_changed,
        ).pack(anchor="w")
        ttk.Radiobutton(
            mode_frame,
            text="Heading-Up",
            variable=self.display_mode_var,
            value="Heading-Up",
            command=self._on_display_mode_changed,
        ).pack(anchor="w")

        # Section 3: Display Options
        options_frame = ttk.LabelFrame(top_frame, text="Display Options", padding=5)
        options_frame.pack(side=tk.LEFT, padx=5, fill=tk.Y)
        ttk.Checkbutton(
            options_frame,
            text="Sim Points",
            variable=self.show_sim_points_var,
            command=self._on_display_options_changed,
        ).grid(row=0, column=0, sticky="w", padx=5)
        ttk.Checkbutton(
            options_frame,
            text="Real Points",
            variable=self.show_real_points_var,
            command=self._on_display_options_changed,
        ).grid(row=0, column=1, sticky="w", padx=5)
        ttk.Checkbutton(
            options_frame,
            text="Sim Trail",
            variable=self.show_sim_trail_var,
            command=self._on_display_options_changed,
        ).grid(row=1, column=0, sticky="w", padx=5)
        ttk.Checkbutton(
            options_frame,
            text="Real Trail",
            variable=self.show_real_trail_var,
            command=self._on_display_options_changed,
        ).grid(row=1, column=1, sticky="w", padx=5)

        # Spacer to push the legend to the right
        spacer = ttk.Frame(top_frame)
        spacer.pack(side=tk.LEFT, expand=True, fill=tk.X)

        # Section 4: Legend
        legend_frame = ttk.LabelFrame(top_frame, text="Legend", padding=5)
        legend_frame.pack(side=tk.LEFT, padx=5, fill=tk.Y)

        # Ownship
        own_sw = tk.Canvas(legend_frame, width=16, height=12, highlightthickness=0)
        own_sw.create_rectangle(0, 0, 16, 12, fill="cyan", outline="black")
        own_sw.grid(row=0, column=0, padx=(0, 4), pady=(0, 2))
        ttk.Label(legend_frame, text="Ownship").grid(row=0, column=1, sticky="w")

        # Simulated
        sim_sw = tk.Canvas(legend_frame, width=16, height=12, highlightthickness=0)
        sim_sw.create_rectangle(0, 0, 16, 12, fill="green", outline="black")
        sim_sw.grid(row=1, column=0, padx=(0, 4), pady=(0, 2))
        ttk.Label(legend_frame, text="Simulated").grid(row=1, column=1, sticky="w")

        # Real
        real_sw = tk.Canvas(legend_frame, width=16, height=12, highlightthickness=0)
        real_sw.create_rectangle(0, 0, 16, 12, fill="red", outline="black")
        real_sw.grid(row=2, column=0, padx=(0, 4))
        ttk.Label(legend_frame, text="Real").grid(row=2, column=1, sticky="w")

    def _create_plot(self):
        fig = Figure(figsize=(5, 5), dpi=100, facecolor="#3E3E3E")
        fig.subplots_adjust(left=0.05, right=0.95, top=0.9, bottom=0.05)
        self.ax = fig.add_subplot(111, projection="polar", facecolor="#2E2E2E")
        self.ax.set_theta_zero_location("N")
        self.ax.set_theta_direction(-1)  # Clockwise
        self.ax.set_rlabel_position(90)
        self.ax.set_ylim(0, self.range_var.get())
        angles_deg = np.arange(0, 360, 30)
        labels = [f"{a}°" for a in angles_deg]
        self.ax.set_thetagrids(angles_deg, labels)
        self.ax.tick_params(axis="x", colors="white", labelsize=8)
        self.ax.tick_params(axis="y", colors="white", labelsize=8)
        self.ax.grid(color="white", linestyle="--", linewidth=0.5, alpha=0.5)
        self.ax.spines["polar"].set_color("white")
        self.ax.set_title("PPI Display", color="white")

        (self._ownship_artist,) = self.ax.plot(
            [0],
            [0],
            marker="^",
            markersize=12,
            color="cyan",
            markeredgecolor="black",
            zorder=10,
            transform=self.ax.transData,
        )
        self._ownship_artist.set_visible(True)

        (self._path_plot,) = self.ax.plot([], [], "g--", linewidth=1.5)
        (self._start_plot,) = self.ax.plot([], [], "go", markersize=8)
        (self._waypoints_plot,) = self.ax.plot([], [], "y+", markersize=10, mew=2)
        self.preview_artists = [self._path_plot, self._start_plot, self._waypoints_plot]

        limit_rad = np.deg2rad(self.scan_limit_deg)
        (self._scan_line_1,) = self.ax.plot(
            [limit_rad, limit_rad], [0, self.max_range], "y--", linewidth=1
        )
        (self._scan_line_2,) = self.ax.plot(
            [-limit_rad, -limit_rad], [0, self.max_range], "y--", linewidth=1
        )
        (self._antenna_line_artist,) = self.ax.plot(
            [], [], color="lightgray", linestyle="--", linewidth=1.2, alpha=0.85
        )

        self.canvas = FigureCanvasTkAgg(fig, master=self)
        self.canvas.draw()
        self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
        self.range_selector.bind("<<ComboboxSelected>>", self._on_range_selected)
        self._update_scan_lines()

    def update_ownship_state(self, heading_deg: float):
        """Updates the ownship's visual representation on the PPI."""
        if self.ownship_heading_deg != heading_deg:
            self.ownship_heading_deg = heading_deg
            self._update_plot_orientation()

    def _update_plot_orientation(self):
        """Applies rotation to the plot or ownship icon based on display mode."""
        if not self.ax or not self._ownship_artist:
            return

        mode = self.display_mode_var.get()
        heading_rad = np.deg2rad(self.ownship_heading_deg)

        if mode == "Heading-Up":
            self.ax.set_theta_offset(math.pi / 2 - heading_rad)
            transform = mpl.transforms.IdentityTransform()
            self._ownship_artist.set_transform(transform)
        else:  # North-Up
            self.ax.set_theta_offset(math.pi / 2)
            transform = (
                mpl.transforms.Affine2D().rotate(-heading_rad) + self.ax.transData
            )
            self._ownship_artist.set_transform(transform)

        if self.canvas:
            self.canvas.draw_idle()

    def clear_all_targets(self):
        """Clears all target artists from the display."""
        all_artists = (
            self.sim_target_artists
            + self.real_target_artists
            + self.sim_trail_artists
            + self.real_trail_artists
            + self.sim_label_artists
            + self.real_label_artists
        )
        for artist in all_artists:
            artist.remove()
        self.sim_target_artists.clear()
        self.real_target_artists.clear()
        self.sim_trail_artists.clear()
        self.real_trail_artists.clear()
        self.sim_label_artists.clear()
        self.real_label_artists.clear()

    def update_simulated_targets(self, targets: List[Target]):
        """Updates and redraws only the simulated targets."""
        self._update_target_category(targets, "simulated")
        if self.canvas:
            self.canvas.draw()

    def update_real_targets(self, targets: List[Target]):
        """Updates and redraws only the real targets."""
        try:
            now = time.monotonic()
            self._real_update_timestamps.append(now)
            if (
                now - self._last_update_summary_time
            ) >= self._update_summary_interval_s:
                self._last_update_summary_time = now
        except Exception:
            pass

        self._update_target_category(targets, "real")
        if self.canvas:
            self.canvas.draw()

    def get_real_update_rate(self, window_seconds: float = 1.0) -> float:
        """
        Returns approximate PPI "real targets" update rate (updates/sec).
        """
        try:
            now = time.monotonic()
            cutoff = now - float(window_seconds)
            count = sum(1 for ts in self._real_update_timestamps if ts >= cutoff)
            return count / float(window_seconds) if window_seconds > 0 else float(count)
        except Exception:
            return 0.0

    def _update_target_category(self, new_data: List[Target], category: str):
        """
        Generic helper to update targets for a specific category ('simulated' or 'real').
        """
        if category == "simulated":
            target_artists, trail_artists, label_artists = (
                self.sim_target_artists,
                self.sim_trail_artists,
                self.sim_label_artists,
            )
            trail_data, show_points, show_trail = (
                self._trails["simulated"],
                self.show_sim_points_var.get(),
                self.show_sim_trail_var.get(),
            )
            color, trail_color = "green", "limegreen"
        else:
            target_artists, trail_artists, label_artists = (
                self.real_target_artists,
                self.real_trail_artists,
                self.real_label_artists,
            )
            trail_data, show_points, show_trail = (
                self._trails["real"],
                self.show_real_points_var.get(),
                self.show_real_trail_var.get(),
            )
            color, trail_color = "red", "tomato"

        for artist in target_artists + trail_artists + label_artists:
            artist.remove()
        target_artists.clear()
        trail_artists.clear()
        label_artists.clear()

        if show_points or show_trail:
            for t in new_data:
                if t.active:
                    pos = (np.deg2rad(t.current_azimuth_deg), t.current_range_nm)
                    trail_data[t.target_id].append(pos)

        if show_points:
            active_targets = [t for t in new_data if t.active]
            if active_targets:
                self._draw_target_visuals(
                    active_targets, color, target_artists, label_artists
                )

            inactive_targets = [t for t in new_data if not t.active]
            if inactive_targets and category == "simulated":
                self._draw_inactive_markers(
                    inactive_targets, color, target_artists, label_artists
                )

        if show_trail:
            self._draw_trails(trail_data, trail_color, trail_artists)

    def _draw_inactive_markers(
        self,
        targets: List[Target],
        color: str,
        artist_list: List,
        label_artist_list: List,
    ):
        for target in targets:
            try:
                r_nm = target.current_range_nm
                theta_rad_plot = np.deg2rad(target.current_azimuth_deg)
                (dot,) = self.ax.plot(
                    theta_rad_plot, r_nm, "o", markersize=6, color=color, alpha=0.6
                )
                artist_list.append(dot)
                (x_mark,) = self.ax.plot(
                    theta_rad_plot,
                    r_nm,
                    marker="x",
                    color="yellow",
                    markersize=8,
                    markeredgewidth=0.9,
                    linestyle="",
                )
                label_artist_list.append(x_mark)
            except Exception:
                pass

    def _draw_target_visuals(
        self,
        targets: List[Target],
        color: str,
        artist_list: List,
        label_artist_list: List,
    ):
        vector_len_nm = self.range_var.get() / 20.0
        marker_size = 8 if color == "green" else 6

        for target in targets:
            r_nm = target.current_range_nm
            theta_rad_plot = np.deg2rad(target.current_azimuth_deg)

            (dot,) = self.ax.plot(
                theta_rad_plot, r_nm, "o", markersize=marker_size, color=color
            )
            artist_list.append(dot)

            heading_relative_to_north_rad = np.deg2rad(target.current_heading_deg)

            x_start_rel_nm = r_nm * math.sin(theta_rad_plot)
            y_start_rel_nm = r_nm * math.cos(theta_rad_plot)

            dx_nm = vector_len_nm * math.sin(heading_relative_to_north_rad)
            dy_nm = vector_len_nm * math.cos(heading_relative_to_north_rad)

            x_end_rel_nm = x_start_rel_nm + dx_nm
            y_end_rel_nm = y_start_rel_nm + dy_nm

            r_end_nm = math.hypot(x_end_rel_nm, y_end_rel_nm)
            theta_end_rad_plot = math.atan2(x_end_rel_nm, y_end_rel_nm)

            (line,) = self.ax.plot(
                [theta_rad_plot, theta_end_rad_plot],
                [r_nm, r_end_nm],
                color=color,
                linewidth=1.2,
            )
            artist_list.append(line)

            txt = self.ax.text(
                theta_rad_plot,
                r_nm + (vector_len_nm * 0.5),
                str(target.target_id),
                color="white",
                fontsize=8,
                ha="center",
                va="bottom",
            )
            label_artist_list.append(txt)

    def _draw_trails(self, trail_data: Dict, color: str, artist_list: List):
        for trail in trail_data.values():
            if len(trail) > 1:
                thetas, rs = zip(*trail)
                (line,) = self.ax.plot(
                    thetas, rs, color=color, linestyle="-", linewidth=0.8, alpha=0.7
                )
                artist_list.append(line)

    def clear_trails(self):
        self._trails["simulated"].clear()
        self._trails["real"].clear()
        self.clear_all_targets()
        if self.canvas:
            self.canvas.draw()

    def _update_scan_lines(self):
        max_r = self.ax.get_ylim()[1]
        limit_rad = np.deg2rad(self.scan_limit_deg)
        self._scan_line_1.set_data([limit_rad, limit_rad], [0, max_r])
        self._scan_line_2.set_data([-limit_rad, -limit_rad], [0, max_r])
        if self._antenna_line_artist:
            data = self._antenna_line_artist.get_data()
            if data and len(data) == 2 and len(data[0]) >= 1:
                theta = data[0][0]
                self._antenna_line_artist.set_data([theta, theta], [0, max_r])

    def _on_range_selected(self, event=None):
        self.ax.set_ylim(0, self.range_var.get())
        self._update_scan_lines()
        if self.canvas:
            self.canvas.draw()

    def _on_antenna_animate_changed(self):
        st = self._antenna_state
        enabled = self.animate_antenna_var.get()
        if not enabled:
            st["animating"] = False
            if self._antenna_line_artist:
                self._antenna_line_artist.set_visible(False)
        else:
            if self._antenna_line_artist:
                self._antenna_line_artist.set_visible(True)
            last_ts, next_ts = st.get("last_ts"), st.get("next_ts")
            if (
                last_ts is not None
                and next_ts is not None
                and next_ts > last_ts
                and not st.get("animating")
            ):
                st["animating"] = True
                self.after(st.get("tick_ms", 33), self._antenna_animation_step)

        if self.canvas:
            self.canvas.draw_idle()

    def clear_previews(self):
        for artist in self.preview_artists:
            artist.set_data([], [])
        for arts in self.preview_path_artists.values():
            for a in arts:
                a.remove()
        self.preview_path_artists.clear()
        if self.canvas:
            self.canvas.draw()

    def draw_scenario_preview(self, scenario):
        self.clear_previews()
        if scenario is None:
            return

        for target in scenario.get_all_targets():
            try:
                path, _ = Target.generate_path_from_waypoints(
                    target.trajectory, target.use_spline
                )
                if not path:
                    continue

                path_thetas, path_rs = [], []
                for point in path:
                    x_ft, y_ft = point[1], point[2]
                    r_ft = math.hypot(x_ft, y_ft)
                    az_rad_plot = math.atan2(x_ft, y_ft)
                    path_rs.append(r_ft / NM_TO_FT)
                    path_thetas.append(az_rad_plot)

                (line_art,) = self.ax.plot(
                    path_thetas,
                    path_rs,
                    color="limegreen",
                    linestyle="--",
                    linewidth=1.2,
                    alpha=0.9,
                )
                (start_art,) = self.ax.plot(
                    [path_thetas[0]], [path_rs[0]], "go", markersize=6
                )
                self.preview_path_artists[target.target_id] = [line_art, start_art]
            except Exception:
                logger.exception(
                    "Failed to draw preview for target %s",
                    getattr(target, "target_id", "?"),
                )

        if self.canvas:
            self.canvas.draw()

    def update_antenna_azimuth(self, az_deg: float, timestamp: float = None):
        try:
            now = time.monotonic()
            ts = float(timestamp) if timestamp is not None else now
            az = float(az_deg) % 360
        except Exception:
            return

        st = self._antenna_state
        if not self.animate_antenna_var.get():
            st.update(
                {
                    "last_az_deg": az,
                    "last_ts": ts,
                    "next_az_deg": az,
                    "next_ts": ts,
                    "animating": False,
                }
            )
            if self._antenna_line_artist:
                self._antenna_line_artist.set_visible(False)
            if self.canvas:
                self.canvas.draw_idle()
            return

        if st["last_az_deg"] is None or st["last_ts"] is None:
            st.update(
                {"last_az_deg": az, "last_ts": ts, "next_az_deg": az, "next_ts": ts}
            )
            self._render_antenna_line(az)
            return

        cur_az, cur_ts = st["last_az_deg"], st["last_ts"]
        next_az, next_ts = st.get("next_az_deg"), st.get("next_ts")

        if next_az is not None and next_ts is not None and next_ts > cur_ts:
            frac = max(0.0, min(1.0, (now - cur_ts) / (next_ts - cur_ts)))
            diff = ((next_az - cur_az + 180) % 360) - 180
            cur_az = (cur_az + diff * frac) % 360
            cur_ts = now

        st.update(
            {"last_az_deg": cur_az, "last_ts": cur_ts, "next_az_deg": az, "next_ts": ts}
        )

        if not st["animating"]:
            st["animating"] = True
            self.after(st["tick_ms"], self._antenna_animation_step)

    def _antenna_animation_step(self):
        st = self._antenna_state
        if not self.animate_antenna_var.get():
            st["animating"] = False
            return

        try:
            last_ts, next_ts = st.get("last_ts"), st.get("next_ts")
            last_az, next_az = st.get("last_az_deg"), st.get("next_az_deg")
            now = time.monotonic()

            if any(v is None for v in [last_az, next_az, last_ts, next_ts]):
                st["animating"] = False
                return

            if next_ts <= last_ts:
                # No upcoming timestamp to interpolate towards. Instead
                # perform a small idle rotation so the antenna appears to
                # sweep even when updates are sparse or timestamps do not
                # advance. This keeps the UI lively and avoids a static
                # antenna when the data source doesn't provide frequent
                # azimuth samples.
                try:
                    idle_speed = float(st.get("idle_rotation_deg_per_s", 30.0))
                    # rotate since last_ts using idle speed
                    dt = max(0.0, now - (last_ts or now))
                    cur = (last_az + idle_speed * dt) % 360
                    # update last_ts so subsequent steps continue smoothly
                    st["last_ts"] = now
                    st["last_az_deg"] = cur
                    # keep animating until explicit stop
                    st["animating"] = True
                except Exception:
                    cur = next_az
                    st["animating"] = False
            else:
                frac = max(0.0, min(1.0, (now - last_ts) / (next_ts - last_ts)))
                diff = ((next_az - last_az + 180) % 360) - 180
                cur = (last_az + diff * frac) % 360
                if frac >= 1.0:
                    st["last_az_deg"], st["last_ts"] = next_az, next_ts
                    st["animating"] = False

            self._render_antenna_line(cur)
        except Exception:
            st["animating"] = False

        if st["animating"]:
            self.after(st["tick_ms"], self._antenna_animation_step)

    def _render_antenna_line(self, az_deg: float):
        try:
            theta = np.deg2rad(float(az_deg))
            max_r = self.ax.get_ylim()[1]
            if self._antenna_line_artist:
                self._antenna_line_artist.set_visible(self.animate_antenna_var.get())
                self._antenna_line_artist.set_data([theta, theta], [0, max_r])
            if self.canvas:
                self.canvas.draw_idle()
        except Exception:
            pass

    def draw_trajectory_preview(self, waypoints: List[Waypoint], use_spline: bool):
        self.clear_previews()
        self.clear_trails()
        if not waypoints or waypoints[0].maneuver_type != ManeuverType.FLY_TO_POINT:
            return

        path, _ = Target.generate_path_from_waypoints(waypoints, use_spline)
        if not path:
            return

        path_thetas, path_rs = [], []
        for point in path:
            x_ft, y_ft = point[1], point[2]
            r_ft = math.hypot(x_ft, y_ft)
            az_rad_plot = math.atan2(x_ft, y_ft)
            path_rs.append(r_ft / NM_TO_FT)
            path_thetas.append(az_rad_plot)

        self._path_plot.set_data(path_thetas, path_rs)

        wp_thetas, wp_rs = [], []
        for wp in waypoints:
            if wp.maneuver_type == ManeuverType.FLY_TO_POINT:
                r_nm = wp.target_range_nm or 0.0
                az_rad_plot = np.deg2rad(wp.target_azimuth_deg or 0.0)
                wp_rs.append(r_nm)
                wp_thetas.append(az_rad_plot)
        self._waypoints_plot.set_data(wp_thetas, wp_rs)

        start_wp = waypoints[0]
        start_r = start_wp.target_range_nm or 0.0
        start_theta = np.deg2rad(start_wp.target_azimuth_deg or 0.0)
        self._start_plot.set_data([start_theta], [start_r])

        if self.canvas:
            self.canvas.draw()

    def reconfigure_radar(self, max_range_nm: int, scan_limit_deg: int):
        self.max_range, self.scan_limit_deg = max_range_nm, scan_limit_deg
        steps = [10, 20, 40, 80, 100, 160, 240, 320]
        valid_steps = sorted(
            [s for s in steps if s <= max_range_nm]
            + ([max_range_nm] if max_range_nm not in steps else [])
        )
        self.range_selector["values"] = valid_steps
        if self.range_var.get() not in valid_steps:
            self.range_var.set(max_range_nm)
        self._on_range_selected()
