//! Routing engine: applies a device input primitive against the active
//! mappings to produce zero or more `(service_type, target, intent)` triples.

use std::collections::HashMap;

use tokio::sync::RwLock;
use uuid::Uuid;
use weave_contracts::{FeedbackRule, Mapping, Route, TargetCandidate};

use super::intent::{InputPrimitive, Intent};

/// A concrete intent destined for a specific service target.
#[derive(Debug, Clone)]
pub struct RoutedIntent {
    pub service_type: String,
    pub service_target: String,
    pub intent: Intent,
}

/// Per-(device_type, device_id) transient state used by target-selection
/// mode. Keyed off the mapping that declared `target_switch_on`. While
/// this struct is in `RoutingEngine::selection`, the device has entered
/// mode: `Rotate` browses `cursor`, `Press` commits, any other input
/// cancels.
#[derive(Debug, Clone)]
pub struct SelectionMode {
    pub mapping_id: Uuid,
    pub edge_id: String,
    pub candidates: Vec<TargetCandidate>,
    pub cursor: usize,
    /// Accumulated rotation delta since the last cursor step. Nuimo emits
    /// rotation events at the characteristic update rate — each encoder
    /// tick is ~1/2650 of a full turn — so a "one tick = one candidate"
    /// policy skips through the whole list on a light nudge. The
    /// accumulator turns that into "one candidate per `SELECTION_ROTATION_STEP`
    /// radians-fraction of travel" so the selection ramp is legible at
    /// hardware rotation speeds.
    rotation_accumulator: f64,
}

/// Fraction of a full Nuimo turn required to advance the selection
/// cursor by one candidate. `1.0` = one full 360° turn; `0.25` = a
/// quarter turn. Tuned experimentally: for the typical 2-4 candidate
/// case this gives roughly one candidate per thumb-flick, versus the
/// pre-damping behaviour where a finger slip cycled through every
/// candidate in the list.
pub const SELECTION_ROTATION_STEP: f64 = 0.25;

impl SelectionMode {
    pub fn new(
        mapping_id: Uuid,
        edge_id: String,
        candidates: Vec<TargetCandidate>,
        cursor: usize,
    ) -> Self {
        Self {
            mapping_id,
            edge_id,
            candidates,
            cursor,
            rotation_accumulator: 0.0,
        }
    }

    fn current(&self) -> &TargetCandidate {
        &self.candidates[self.cursor]
    }

    /// Add `delta` to the rotation accumulator and advance the cursor by
    /// one candidate for each full `SELECTION_ROTATION_STEP` worth of
    /// accumulated travel. Returns `true` if the cursor moved — callers
    /// use this to suppress redundant LED redraws for sub-threshold
    /// rotations.
    fn advance(&mut self, delta: f64) -> bool {
        let n = self.candidates.len();
        if n == 0 {
            return false;
        }
        self.rotation_accumulator += delta;
        let mut moved = false;
        while self.rotation_accumulator >= SELECTION_ROTATION_STEP {
            self.cursor = (self.cursor + 1) % n;
            self.rotation_accumulator -= SELECTION_ROTATION_STEP;
            moved = true;
        }
        while self.rotation_accumulator <= -SELECTION_ROTATION_STEP {
            self.cursor = (self.cursor + n - 1) % n;
            self.rotation_accumulator += SELECTION_ROTATION_STEP;
            moved = true;
        }
        moved
    }
}

/// One outcome of routing a single input primitive. `Normal` is the
/// existing "route to zero or more intents" path; the other variants are
/// target-selection side effects that the caller must translate into LED
/// feedback or a server-bound `SwitchTarget` frame.
#[derive(Debug, Clone)]
pub enum RouteOutcome {
    Normal(Vec<RoutedIntent>),
    /// Device just entered selection mode; show `glyph` on the LED.
    EnterSelection {
        edge_id: String,
        mapping_id: Uuid,
        glyph: String,
    },
    /// Device still in selection mode, cursor moved; show `glyph`.
    UpdateSelection {
        mapping_id: Uuid,
        glyph: String,
    },
    /// Device committed the selection; caller sends
    /// `EdgeToServer::SwitchTarget` and optionally clears the LED.
    CommitSelection {
        edge_id: String,
        mapping_id: Uuid,
        service_target: String,
    },
    /// Device exited selection mode without committing (non-rotate/press
    /// input). Caller should clear any lingering LED feedback.
    CancelSelection {
        mapping_id: Uuid,
    },
}

/// Thread-safe registry of currently-active mappings, keyed by
/// `(device_type, device_id)` for O(1) lookup per input event.
#[derive(Default)]
pub struct RoutingEngine {
    by_device: RwLock<HashMap<(String, String), Vec<Mapping>>>,
    selection: RwLock<HashMap<(String, String), SelectionMode>>,
}

impl RoutingEngine {
    pub fn new() -> Self {
        Self::default()
    }

    /// Replace all mappings with the provided set. Used on `config_full` push.
    pub async fn replace_all(&self, mappings: Vec<Mapping>) {
        let mut by_device: HashMap<(String, String), Vec<Mapping>> = HashMap::new();
        for m in mappings {
            by_device
                .entry((m.device_type.clone(), m.device_id.clone()))
                .or_default()
                .push(m);
        }
        *self.by_device.write().await = by_device;
    }

    /// Insert or replace one mapping, keyed by `mapping_id`. Used on
    /// `config_patch` upsert.
    pub async fn upsert_mapping(&self, mapping: Mapping) {
        let mut guard = self.by_device.write().await;
        // Remove any prior entry with the same mapping_id, regardless of
        // device key (handles device reassignment).
        for list in guard.values_mut() {
            list.retain(|m| m.mapping_id != mapping.mapping_id);
        }
        guard
            .entry((mapping.device_type.clone(), mapping.device_id.clone()))
            .or_default()
            .push(mapping);
    }

    /// Remove any mapping with the given `mapping_id`. Used on
    /// `config_patch` delete.
    pub async fn remove_mapping(&self, id: &uuid::Uuid) {
        let mut guard = self.by_device.write().await;
        for list in guard.values_mut() {
            list.retain(|m| &m.mapping_id != id);
        }
        // Prune empty device buckets so route() stays tight.
        guard.retain(|_, list| !list.is_empty());
    }

    /// Snapshot every mapping currently held, grouped or not. Used to
    /// persist the local cache after an incremental patch.
    pub async fn snapshot(&self) -> Vec<Mapping> {
        self.by_device
            .read()
            .await
            .values()
            .flatten()
            .cloned()
            .collect()
    }

    /// Feedback rules attached to whichever mapping covers the given
    /// `(service_type, target)` — either as its primary target or as a
    /// listed candidate (including candidates that override the mapping's
    /// `service_type`). Returns an empty vec when no mapping covers the
    /// target, letting the caller fall back to hardcoded defaults.
    ///
    /// Feedback is stored at the mapping level (all candidates share one
    /// rule set), so a candidate match still returns the mapping's
    /// `feedback`.
    pub async fn feedback_rules_for_target(
        &self,
        service_type: &str,
        target: &str,
    ) -> Vec<FeedbackRule> {
        let guard = self.by_device.read().await;
        for list in guard.values() {
            if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
                return rules;
            }
        }
        Vec::new()
    }

    /// Same as `feedback_rules_for_target` but scoped to one `(device_type,
    /// device_id)` bucket. Use this from per-device feedback pumps so a
    /// Nuimo only reacts when the state change belongs to a mapping it
    /// owns — otherwise a second Nuimo mapped elsewhere would flash glyphs
    /// for unrelated service activity.
    ///
    /// Returns `None` when no mapping on this device covers
    /// `(service_type, target)` — the caller should skip. Returns
    /// `Some(vec)` when a mapping exists; the vec may be empty, which
    /// signals "mapping exists but user configured no rules — fall back
    /// to hardcoded defaults" (preserves single-device behavior).
    pub async fn feedback_rules_for_device_target(
        &self,
        device_type: &str,
        device_id: &str,
        service_type: &str,
        target: &str,
    ) -> Option<Vec<FeedbackRule>> {
        let guard = self.by_device.read().await;
        let list = guard.get(&(device_type.to_string(), device_id.to_string()))?;
        mapping_rules_for_target(list, service_type, target)
    }

    /// Find every device whose mapping owns `(service_type, target)` and
    /// return its `(device_type, device_id, feedback_rules)`. Used by the
    /// iOS feedback pump, which fans a single state update out to every
    /// LED that should display it — typically 1 device, but two Nuimos
    /// paired to the same iPad both deserve feedback.
    pub async fn feedback_targets_for(
        &self,
        service_type: &str,
        target: &str,
    ) -> Vec<(String, String, Vec<FeedbackRule>)> {
        let guard = self.by_device.read().await;
        let mut out = Vec::new();
        for ((device_type, device_id), list) in guard.iter() {
            if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
                out.push((device_type.clone(), device_id.clone(), rules));
            }
        }
        out
    }

    /// Apply the given input primitive from a specific device, returning every
    /// intent it produces across all matching mappings.
    pub async fn route(
        &self,
        device_type: &str,
        device_id: &str,
        input: &InputPrimitive,
    ) -> Vec<RoutedIntent> {
        let guard = self.by_device.read().await;
        let Some(mappings) = guard.get(&(device_type.to_string(), device_id.to_string())) else {
            return Vec::new();
        };
        route_mappings(mappings, input)
    }

    /// Same dispatch as `route`, but also implements the target-selection
    /// state machine (`Mapping.target_switch_on` + `target_candidates`).
    /// Callers that want on-device target switching should use this
    /// instead of `route`.
    pub async fn route_with_mode(
        &self,
        device_type: &str,
        device_id: &str,
        input: &InputPrimitive,
    ) -> RouteOutcome {
        let key = (device_type.to_string(), device_id.to_string());

        // Already in selection mode for this device: intercept rotate /
        // press / cancel before dispatching to normal routing.
        {
            let mut sel = self.selection.write().await;
            if let Some(mode) = sel.get_mut(&key) {
                match input {
                    InputPrimitive::Rotate { delta } => {
                        // Sub-threshold rotations accumulate without
                        // emitting UpdateSelection — the LED keeps the
                        // current candidate's glyph and no log line
                        // fires until the cursor actually moves.
                        if !mode.advance(*delta) {
                            return RouteOutcome::Normal(Vec::new());
                        }
                        let glyph = mode.current().glyph.clone();
                        let mapping_id = mode.mapping_id;
                        return RouteOutcome::UpdateSelection { mapping_id, glyph };
                    }
                    InputPrimitive::Press => {
                        let mapping_id = mode.mapping_id;
                        let service_target = mode.current().target.clone();
                        let edge_id = mode.edge_id.clone();
                        sel.remove(&key);
                        return RouteOutcome::CommitSelection {
                            edge_id,
                            mapping_id,
                            service_target,
                        };
                    }
                    _ => {
                        let mapping_id = mode.mapping_id;
                        sel.remove(&key);
                        return RouteOutcome::CancelSelection { mapping_id };
                    }
                }
            }
        }

        // Not in mode yet: check whether this input should enter mode on
        // any mapping for this device, else fall through to normal routing.
        let guard = self.by_device.read().await;
        let Some(mappings) = guard.get(&key) else {
            return RouteOutcome::Normal(Vec::new());
        };

        for m in mappings {
            if !m.active {
                continue;
            }
            let Some(switch_on) = m.target_switch_on.as_deref() else {
                continue;
            };
            if m.target_candidates.is_empty() {
                continue;
            }
            if !input.matches_route(switch_on) {
                continue;
            }
            // Enter mode with cursor one step AFTER the current
            // service_target — so swipe_up→press (no rotate) cycles to
            // the next candidate. Rotate still browses from there.
            let current_idx = m
                .target_candidates
                .iter()
                .position(|c| c.target == m.service_target);
            let cursor = match current_idx {
                Some(i) => (i + 1) % m.target_candidates.len(),
                None => 0,
            };
            let mode = SelectionMode::new(
                m.mapping_id,
                m.edge_id.clone(),
                m.target_candidates.clone(),
                cursor,
            );
            let glyph = mode.current().glyph.clone();
            let mapping_id = mode.mapping_id;
            let edge_id = mode.edge_id.clone();
            drop(guard);
            self.selection.write().await.insert(key, mode);
            return RouteOutcome::EnterSelection {
                edge_id,
                mapping_id,
                glyph,
            };
        }

        RouteOutcome::Normal(route_mappings(mappings, input))
    }
}

/// Look for a mapping in `list` whose primary `(service_type, target)` or
/// any `target_candidates` entry matches the given pair. Returns the
/// mapping's `feedback` clone on hit. `None` means no mapping covers the
/// target, letting the caller fall back to defaults.
fn mapping_rules_for_target(
    list: &[Mapping],
    service_type: &str,
    target: &str,
) -> Option<Vec<FeedbackRule>> {
    for m in list {
        if m.service_type == service_type && m.service_target == target {
            return Some(m.feedback.clone());
        }
        for c in &m.target_candidates {
            let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
            if c_service_type == service_type && c.target == target {
                return Some(m.feedback.clone());
            }
        }
    }
    None
}

fn route_mappings(mappings: &[Mapping], input: &InputPrimitive) -> Vec<RoutedIntent> {
    let mut out = Vec::new();
    for m in mappings {
        if !m.active {
            continue;
        }
        // Resolve the effective service_type + routes for the currently
        // active target. When the active target is a cross-service
        // candidate (e.g. Roon-flavoured mapping pointing at a Hue light),
        // `effective_for` returns the candidate's overrides so the
        // dispatcher sees the right adapter + intent set.
        let (service_type, routes) = m.effective_for(&m.service_target);
        for route in routes {
            if !input.matches_route(&route.input) {
                continue;
            }
            if let Some(intent) = build_intent(route, input) {
                out.push(RoutedIntent {
                    service_type: service_type.to_string(),
                    service_target: m.service_target.clone(),
                    intent,
                });
                // Only first matching route per mapping fires.
                break;
            }
        }
    }
    out
}

fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
    let damping = route
        .params
        .get("damping")
        .and_then(|v| v.as_f64())
        .unwrap_or(1.0);

    match route.intent.as_str() {
        "play" => Some(Intent::Play),
        "pause" => Some(Intent::Pause),
        "play_pause" | "playpause" => Some(Intent::PlayPause),
        "stop" => Some(Intent::Stop),
        "next" => Some(Intent::Next),
        "previous" => Some(Intent::Previous),
        "mute" => Some(Intent::Mute),
        "unmute" => Some(Intent::Unmute),
        "power_toggle" => Some(Intent::PowerToggle),
        "power_on" => Some(Intent::PowerOn),
        "power_off" => Some(Intent::PowerOff),
        "volume_change" => input
            .continuous_value()
            .map(|v| Intent::VolumeChange { delta: v * damping }),
        "volume_set" => route
            .params
            .get("value")
            .and_then(|v| v.as_f64())
            .map(|value| Intent::VolumeSet { value }),
        "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
            seconds: v * damping,
        }),
        "brightness_change" => input
            .continuous_value()
            .map(|v| Intent::BrightnessChange { delta: v * damping }),
        "color_temperature_change" => input
            .continuous_value()
            .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Direction;
    use std::collections::BTreeMap;
    use uuid::Uuid;
    use weave_contracts::Route;

    fn rotate_mapping() -> Mapping {
        Mapping {
            mapping_id: Uuid::new_v4(),
            edge_id: "living-room".into(),
            device_type: "nuimo".into(),
            device_id: "C3:81:DF:4E".into(),
            service_type: "roon".into(),
            service_target: "zone-1".into(),
            routes: vec![Route {
                input: "rotate".into(),
                intent: "volume_change".into(),
                params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
            }],
            feedback: vec![],
            active: true,
            target_candidates: vec![],
            target_switch_on: None,
        }
    }

    #[tokio::test]
    async fn rotate_produces_volume_change_with_damping() {
        let engine = RoutingEngine::new();
        engine.replace_all(vec![rotate_mapping()]).await;

        let out = engine
            .route(
                "nuimo",
                "C3:81:DF:4E",
                &InputPrimitive::Rotate { delta: 0.03 },
            )
            .await;
        assert_eq!(out.len(), 1);
        match &out[0].intent {
            Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
            other => panic!("expected VolumeChange, got {:?}", other),
        }
        assert_eq!(out[0].service_type, "roon");
        assert_eq!(out[0].service_target, "zone-1");
    }

    #[tokio::test]
    async fn inactive_mappings_are_skipped() {
        let mut m = rotate_mapping();
        m.active = false;
        let engine = RoutingEngine::new();
        engine.replace_all(vec![m]).await;

        let out = engine
            .route(
                "nuimo",
                "C3:81:DF:4E",
                &InputPrimitive::Rotate { delta: 0.03 },
            )
            .await;
        assert!(out.is_empty());
    }

    #[tokio::test]
    async fn unknown_device_returns_empty() {
        let engine = RoutingEngine::new();
        engine.replace_all(vec![rotate_mapping()]).await;

        let out = engine
            .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
            .await;
        assert!(out.is_empty());
    }

    fn selection_mapping() -> Mapping {
        let mut m = rotate_mapping();
        m.service_target = "target-A".into();
        m.target_switch_on = Some("swipe_up".into());
        m.target_candidates = vec![
            TargetCandidate {
                target: "target-A".into(),
                label: "A".into(),
                glyph: "glyph-a".into(),
                service_type: None,
                routes: None,
            },
            TargetCandidate {
                target: "target-B".into(),
                label: "B".into(),
                glyph: "glyph-b".into(),
                service_type: None,
                routes: None,
            },
        ];
        m
    }

    /// A mapping whose primary target is a Roon zone and whose secondary
    /// candidate is a Hue light with its own route table. Used to exercise
    /// the `effective_for` cross-service override path.
    fn cross_service_mapping() -> Mapping {
        let mut m = rotate_mapping();
        m.service_target = "roon-zone".into();
        m.target_switch_on = Some("long_press".into());
        m.target_candidates = vec![
            TargetCandidate {
                target: "roon-zone".into(),
                label: "Roon".into(),
                glyph: "roon".into(),
                service_type: None,
                routes: None,
            },
            TargetCandidate {
                target: "hue-light".into(),
                label: "Hue".into(),
                glyph: "hue".into(),
                service_type: Some("hue".into()),
                routes: Some(vec![Route {
                    input: "rotate".into(),
                    intent: "brightness_change".into(),
                    params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
                }]),
            },
        ];
        m
    }

    #[test]
    fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
        let m = cross_service_mapping();
        let (svc, routes) = m.effective_for("roon-zone");
        assert_eq!(svc, "roon");
        assert_eq!(routes.len(), 1);
        assert_eq!(routes[0].intent, "volume_change");
    }

    #[test]
    fn effective_for_returns_candidate_overrides_when_present() {
        let m = cross_service_mapping();
        let (svc, routes) = m.effective_for("hue-light");
        assert_eq!(svc, "hue");
        assert_eq!(routes.len(), 1);
        assert_eq!(routes[0].intent, "brightness_change");
    }

    #[tokio::test]
    async fn rotate_on_cross_service_candidate_produces_hue_intent() {
        let mut m = cross_service_mapping();
        // Simulate the user having switched the active target to the Hue
        // candidate (as if selection mode had committed on it).
        m.service_target = "hue-light".into();

        let engine = RoutingEngine::new();
        engine.replace_all(vec![m]).await;

        let out = engine
            .route(
                "nuimo",
                "C3:81:DF:4E",
                &InputPrimitive::Rotate { delta: 0.1 },
            )
            .await;
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].service_type, "hue");
        assert_eq!(out[0].service_target, "hue-light");
        match &out[0].intent {
            Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
            other => panic!("expected BrightnessChange, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn swipe_up_press_cycles_to_next_target_without_rotate() {
        let engine = RoutingEngine::new();
        engine.replace_all(vec![selection_mapping()]).await;

        match engine
            .route_with_mode(
                "nuimo",
                "C3:81:DF:4E",
                &InputPrimitive::Swipe {
                    direction: Direction::Up,
                },
            )
            .await
        {
            RouteOutcome::EnterSelection { glyph, .. } => {
                // Entering mode from target-A should point at target-B.
                assert_eq!(glyph, "glyph-b");
            }
            other => panic!("expected EnterSelection, got {:?}", other),
        }

        match engine
            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
            .await
        {
            RouteOutcome::CommitSelection { service_target, .. } => {
                assert_eq!(service_target, "target-B")
            }
            other => panic!("expected CommitSelection, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn rotate_then_press_commits_advanced_candidate() {
        let engine = RoutingEngine::new();
        engine.replace_all(vec![selection_mapping()]).await;

        // Enter: cursor jumps to target-B (position 1).
        let _ = engine
            .route_with_mode(
                "nuimo",
                "C3:81:DF:4E",
                &InputPrimitive::Swipe {
                    direction: Direction::Up,
                },
            )
            .await;
        // Rotate forward past the damping threshold — wraps from B back to A.
        match engine
            .route_with_mode(
                "nuimo",
                "C3:81:DF:4E",
                &InputPrimitive::Rotate {
                    delta: SELECTION_ROTATION_STEP,
                },
            )
            .await
        {
            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
            other => panic!("expected UpdateSelection, got {:?}", other),
        }
        match engine
            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
            .await
        {
            RouteOutcome::CommitSelection { service_target, .. } => {
                assert_eq!(service_target, "target-A")
            }
            other => panic!("expected CommitSelection, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn sub_threshold_rotate_keeps_cursor_still() {
        let engine = RoutingEngine::new();
        engine.replace_all(vec![selection_mapping()]).await;

        // Enter selection at target-B.
        let _ = engine
            .route_with_mode(
                "nuimo",
                "C3:81:DF:4E",
                &InputPrimitive::Swipe {
                    direction: Direction::Up,
                },
            )
            .await;

        // A single small rotation accumulates but does not cross the
        // threshold. Caller sees an empty Normal outcome — no LED push,
        // no state change.
        match engine
            .route_with_mode(
                "nuimo",
                "C3:81:DF:4E",
                &InputPrimitive::Rotate {
                    delta: SELECTION_ROTATION_STEP / 2.0,
                },
            )
            .await
        {
            RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
            other => panic!("expected Normal(empty), got {:?}", other),
        }

        // A second small rotation pushes the accumulator over the
        // threshold — cursor moves.
        match engine
            .route_with_mode(
                "nuimo",
                "C3:81:DF:4E",
                &InputPrimitive::Rotate {
                    delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
                },
            )
            .await
        {
            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
            other => panic!("expected UpdateSelection, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn large_rotate_produces_single_step_per_outcome() {
        // advance() advances until the accumulator is below the threshold,
        // but route_with_mode yields a single RouteOutcome per input event.
        // Verify a 2.4-step delta lands on a cursor position consistent
        // with two advances (not one, not four).
        let engine = RoutingEngine::new();
        engine.replace_all(vec![selection_mapping()]).await;

        let _ = engine
            .route_with_mode(
                "nuimo",
                "C3:81:DF:4E",
                &InputPrimitive::Swipe {
                    direction: Direction::Up,
                },
            )
            .await;
        // Starting cursor is at B (index 1). 2 steps forward in a 2-entry
        // list wraps back to B. Use a delta that advances exactly twice
        // to land deterministically.
        match engine
            .route_with_mode(
                "nuimo",
                "C3:81:DF:4E",
                &InputPrimitive::Rotate {
                    delta: SELECTION_ROTATION_STEP * 2.0,
                },
            )
            .await
        {
            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
            other => panic!("expected UpdateSelection, got {:?}", other),
        }
    }

    fn playback_glyph_rule() -> FeedbackRule {
        FeedbackRule {
            state: "playback".into(),
            feedback_type: "glyph".into(),
            mapping: serde_json::json!({
                "playing": "play",
                "paused": "pause",
                "stopped": "pause",
            }),
        }
    }

    fn mapping_with_feedback() -> Mapping {
        let mut m = rotate_mapping();
        m.feedback = vec![playback_glyph_rule()];
        m
    }

    #[tokio::test]
    async fn feedback_rules_match_primary_target() {
        let engine = RoutingEngine::new();
        engine.replace_all(vec![mapping_with_feedback()]).await;

        let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].state, "playback");
    }

    #[tokio::test]
    async fn feedback_rules_match_candidate_override_service_type() {
        let mut m = mapping_with_feedback();
        m.target_candidates = vec![TargetCandidate {
            target: "hue-light-1".into(),
            label: "Living".into(),
            glyph: "link".into(),
            service_type: Some("hue".into()),
            routes: None,
        }];
        let engine = RoutingEngine::new();
        engine.replace_all(vec![m]).await;

        let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
        assert_eq!(
            rules.len(),
            1,
            "candidate with overridden service_type inherits mapping feedback",
        );
    }

    #[tokio::test]
    async fn feedback_rules_empty_for_unknown_target() {
        let engine = RoutingEngine::new();
        engine.replace_all(vec![mapping_with_feedback()]).await;

        let rules = engine
            .feedback_rules_for_target("roon", "some-other-zone")
            .await;
        assert!(rules.is_empty());
    }

    #[tokio::test]
    async fn feedback_rules_for_device_target_scopes_by_device() {
        // Two mappings for the same Roon zone, but on different Nuimos.
        // Only the device that owns the mapping should receive feedback.
        let mut m_a = mapping_with_feedback();
        m_a.device_id = "nuimo-a".into();
        let mut m_b = mapping_with_feedback();
        m_b.mapping_id = Uuid::new_v4();
        m_b.device_id = "nuimo-b".into();
        m_b.service_target = "zone-b".into();

        let engine = RoutingEngine::new();
        engine.replace_all(vec![m_a, m_b]).await;

        let rules_a = engine
            .feedback_rules_for_device_target("nuimo", "nuimo-a", "roon", "zone-1")
            .await;
        let rules_a = rules_a.expect("nuimo-a owns zone-1");
        assert_eq!(rules_a.len(), 1);

        let rules_b_wrong = engine
            .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-1")
            .await;
        assert!(
            rules_b_wrong.is_none(),
            "nuimo-b does not own zone-1 — must skip entirely",
        );

        let rules_b_right = engine
            .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-b")
            .await;
        let rules_b_right = rules_b_right.expect("nuimo-b owns zone-b");
        assert_eq!(rules_b_right.len(), 1);
    }

    #[tokio::test]
    async fn feedback_rules_for_device_target_none_for_unknown_device() {
        let engine = RoutingEngine::new();
        engine.replace_all(vec![mapping_with_feedback()]).await;

        let rules = engine
            .feedback_rules_for_device_target("nuimo", "unknown-device", "roon", "zone-1")
            .await;
        assert!(
            rules.is_none(),
            "no mapping for unknown device — caller should skip",
        );
    }

    #[tokio::test]
    async fn feedback_rules_for_device_target_some_empty_for_mapping_without_rules() {
        // A mapping exists for the device + target, but `feedback` is empty.
        // Result must be `Some(vec![])` so the caller falls back to
        // hardcoded defaults (preserves single-device behavior).
        let m = rotate_mapping(); // no feedback rules
        let engine = RoutingEngine::new();
        engine.replace_all(vec![m]).await;

        let rules = engine
            .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
            .await;
        let rules = rules.expect("mapping exists — caller should fall back to defaults");
        assert!(
            rules.is_empty(),
            "no explicit rules configured — pump will use FeedbackPlan defaults",
        );
    }
}
