//! RufloRunner trait + runner implementations.
//!
//! The ruflo agent is a Node.js process that exposes an MCP-over-stdio
//! interface for LLM-grade intent disambiguation. HOMECORE-ASSIST manages
//! a long-lived subprocess via `tokio::process::Child`.
//!
//! ## Runners
//!
//! - [`LocalRunner`] — the real, dependency-free response path. It runs an
//!   actual [`IntentRecognizer`](crate::recognizer::IntentRecognizer) over the
//!   incoming utterance and returns a fully-formed [`RufloResponse`] with the
//!   resolved intent and a spoken acknowledgement. No external process — this
//!   is the honest production path when no `ruflo-agent.js` is installed.
//! - [`NoopRunner`] — an explicit, honest no-op. Before `spawn`, `send_request`
//!   returns a typed [`AssistError::NotStarted`]; after `spawn`, it returns an
//!   *empty-but-typed* [`RufloResponse`] so the pipeline can legitimately fall
//!   through to its regex recognizer. It never pretends an absent LLM answered.
//!
//! ## Subprocess runner (data-gated)
//!
//! A real `node ruflo-agent.js` subprocess runner with Windows-safe teardown
//! (ADR-133 §Q3) is genuinely gated on the `ruflo-agent.js` script existing on
//! disk. When that script is absent, [`LocalRunner`] is the honest path — it
//! resolves intents locally rather than fabricating a subprocess response.

use std::sync::Arc;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::intent::Intent;
use crate::recognizer::IntentRecognizer;

/// Error type for the assist pipeline (runner + pipeline-level errors).
#[derive(Error, Debug)]
pub enum AssistError {
    #[error("runner not started")]
    NotStarted,
    #[error("runner IO error: {0}")]
    Io(String),
    #[error("runner response parse error: {0}")]
    ParseError(String),
    #[error("recognizer error: {0}")]
    Recognizer(#[from] crate::recognizer::RecognizerError),
    #[error("handler error: {0}")]
    Handler(#[from] crate::handler::HandlerError),
    #[error("no handler registered for intent: {0}")]
    NoHandler(String),
}

/// Configuration for launching the ruflo agent subprocess.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RufloRunnerOpts {
    /// Path to the `ruflo-agent.js` entry point.
    pub script_path: String,
    /// Additional environment variables to pass to the subprocess.
    pub env: std::collections::HashMap<String, String>,
    /// Request timeout in milliseconds (default 5000).
    pub timeout_ms: u64,
}

impl Default for RufloRunnerOpts {
    fn default() -> Self {
        Self {
            script_path: "ruflo-agent.js".into(),
            env: Default::default(),
            timeout_ms: 5000,
        }
    }
}

/// JSON response from the ruflo agent subprocess.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RufloResponse {
    /// Recognised intent, if the LLM resolved one.
    pub intent: Option<Intent>,
    /// Spoken text from the LLM, if any.
    pub speech: Option<String>,
}

/// Trait for the ruflo agent runner.
///
/// Implemented by [`LocalRunner`] (real recognizer-backed resolution) and
/// [`NoopRunner`] (honest no-op). A live `node ruflo-agent.js` subprocess
/// runner with Windows-safe teardown (ADR-133 §Q3) is the data-gated future
/// implementation.
#[async_trait]
pub trait RufloRunner: Send + Sync + 'static {
    /// Spawn (or reconnect to) the ruflo agent subprocess.
    async fn spawn(&mut self, opts: RufloRunnerOpts) -> Result<(), AssistError>;

    /// Send an utterance payload to the agent and await a response.
    ///
    /// `payload` is an arbitrary JSON object; at minimum it should include
    /// `{ "utterance": "...", "language": "..." }`.
    async fn send_request(
        &self,
        payload: serde_json::Value,
    ) -> Result<RufloResponse, AssistError>;

    /// Gracefully shut down the subprocess.
    ///
    /// Must be idempotent — calling `shutdown` on an already-stopped runner
    /// must return `Ok(())` rather than an error.
    async fn shutdown(&mut self) -> Result<(), AssistError>;
}

/// Honest no-op implementation.
///
/// `NoopRunner` spawns no subprocess. It is *honest* about state:
/// - Calling `send_request` **before** `spawn` returns
///   [`AssistError::NotStarted`] — not a silent empty response.
/// - After `spawn`, `send_request` returns an empty-but-typed
///   [`RufloResponse`] (`intent: None`), which the pipeline reads as an
///   explicit "no LLM opinion" signal and legitimately falls through to its
///   regex recognizer.
///
/// Use [`LocalRunner`] when you want a runner that actually resolves intents.
#[derive(Default)]
pub struct NoopRunner {
    started: bool,
}

impl NoopRunner {
    pub fn new() -> Self {
        Self { started: false }
    }
}

#[async_trait]
impl RufloRunner for NoopRunner {
    async fn spawn(&mut self, _opts: RufloRunnerOpts) -> Result<(), AssistError> {
        self.started = true;
        tracing::debug!("NoopRunner: spawn called (no subprocess — explicit no-op)");
        Ok(())
    }

    async fn send_request(
        &self,
        _payload: serde_json::Value,
    ) -> Result<RufloResponse, AssistError> {
        // Honest: refuse to answer if not started rather than fabricating a
        // response. After spawn, return an explicit "no opinion" so the
        // pipeline can fall through deliberately.
        if !self.started {
            return Err(AssistError::NotStarted);
        }
        Ok(RufloResponse {
            intent: None,
            speech: None,
        })
    }

    async fn shutdown(&mut self) -> Result<(), AssistError> {
        // Idempotent: Ok whether or not spawn was called.
        self.started = false;
        tracing::debug!("NoopRunner: shutdown called (idempotent)");
        Ok(())
    }
}

/// Real, dependency-free runner that resolves intents locally.
///
/// `LocalRunner` wraps any [`IntentRecognizer`]. On `send_request` it:
/// 1. Extracts `utterance` + `language` from the JSON payload.
/// 2. Runs the recognizer over the utterance.
/// 3. On a match, returns a `RufloResponse` carrying the resolved [`Intent`]
///    plus a real spoken acknowledgement.
/// 4. On no match, returns an empty `RufloResponse` (intent `None`) so the
///    caller can fall through — this is a genuine "nothing recognised", not a
///    swallowed error.
///
/// This is the honest production path when no Node.js `ruflo-agent.js` LLM
/// process is installed: it answers with the actual recognizer pipeline.
pub struct LocalRunner<R: IntentRecognizer> {
    recognizer: Arc<R>,
    started: bool,
}

impl<R: IntentRecognizer> LocalRunner<R> {
    /// Build a `LocalRunner` over the given recognizer.
    pub fn new(recognizer: R) -> Self {
        Self {
            recognizer: Arc::new(recognizer),
            started: false,
        }
    }

    /// Build a `LocalRunner` from a shared recognizer handle.
    pub fn from_arc(recognizer: Arc<R>) -> Self {
        Self {
            recognizer,
            started: false,
        }
    }

    /// Compose the spoken acknowledgement for a resolved intent.
    ///
    /// Mirrors the speech the built-in handlers would synthesise, so the
    /// runner's `speech` field is consistent with the handler path.
    fn speech_for(intent: &Intent) -> String {
        match (intent.name.as_str(), intent.entity_id()) {
            ("HassTurnOn", Some(e)) => format!("Turned on {e}."),
            ("HassTurnOff", Some(e)) => format!("Turned off {e}."),
            ("HassLightSet", Some(e)) => format!("Done, adjusted {e}."),
            ("HassNevermind", _) => "Okay, never mind.".to_owned(),
            ("HassCancelAll", _) => "Cancelled all running automations.".to_owned(),
            (name, Some(e)) => format!("Resolved {name} for {e}."),
            (name, None) => format!("Resolved {name}."),
        }
    }
}

#[async_trait]
impl<R: IntentRecognizer> RufloRunner for LocalRunner<R> {
    async fn spawn(&mut self, _opts: RufloRunnerOpts) -> Result<(), AssistError> {
        self.started = true;
        tracing::debug!("LocalRunner: ready (local recognizer-backed resolution)");
        Ok(())
    }

    async fn send_request(
        &self,
        payload: serde_json::Value,
    ) -> Result<RufloResponse, AssistError> {
        if !self.started {
            return Err(AssistError::NotStarted);
        }

        let utterance = payload
            .get("utterance")
            .and_then(|v| v.as_str())
            .ok_or_else(|| AssistError::ParseError("payload missing `utterance`".into()))?;
        let language = payload
            .get("language")
            .and_then(|v| v.as_str())
            .unwrap_or("en");

        // Run the REAL recognizer pipeline.
        let intent = self.recognizer.recognize(utterance, language).await?;

        match intent {
            Some(intent) => {
                let speech = Self::speech_for(&intent);
                tracing::debug!(
                    intent = %intent.name,
                    "LocalRunner: resolved intent for utterance"
                );
                Ok(RufloResponse {
                    intent: Some(intent),
                    speech: Some(speech),
                })
            }
            None => {
                // Genuine no-match — fall through, not a silent failure.
                tracing::debug!("LocalRunner: no intent recognised — falling through");
                Ok(RufloResponse {
                    intent: None,
                    speech: None,
                })
            }
        }
    }

    async fn shutdown(&mut self) -> Result<(), AssistError> {
        self.started = false;
        tracing::debug!("LocalRunner: shutdown (idempotent)");
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::recognizer::RegexIntentRecognizer;

    async fn turn_on_recognizer() -> RegexIntentRecognizer {
        let r = RegexIntentRecognizer::new();
        r.register(
            "HassTurnOn",
            r"turn on (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)",
            "*",
        )
        .await
        .unwrap();
        r
    }

    #[tokio::test]
    async fn noop_runner_spawn_returns_ok() {
        let mut runner = NoopRunner::new();
        let result = runner.spawn(RufloRunnerOpts::default()).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn noop_runner_send_before_spawn_is_not_started() {
        // Honest behaviour: un-spawned runner must NOT fabricate a response.
        let runner = NoopRunner::new();
        let err = runner
            .send_request(serde_json::json!({"utterance": "turn on the light"}))
            .await
            .unwrap_err();
        assert!(matches!(err, AssistError::NotStarted));
    }

    #[tokio::test]
    async fn noop_runner_after_spawn_returns_explicit_no_opinion() {
        let mut runner = NoopRunner::new();
        runner.spawn(RufloRunnerOpts::default()).await.unwrap();
        let resp = runner
            .send_request(serde_json::json!({"utterance": "turn on the light", "language": "en"}))
            .await
            .unwrap();
        // Explicit "no opinion" so the pipeline can fall through deliberately.
        assert!(resp.intent.is_none());
        assert!(resp.speech.is_none());
    }

    #[tokio::test]
    async fn noop_runner_shutdown_is_idempotent() {
        let mut runner = NoopRunner::new();
        // First shutdown without spawn — must not error.
        assert!(runner.shutdown().await.is_ok());
        // Spawn then shutdown — must not error.
        runner.spawn(RufloRunnerOpts::default()).await.unwrap();
        assert!(runner.shutdown().await.is_ok());
        // Second shutdown — must still not error.
        assert!(runner.shutdown().await.is_ok());
    }

    // ── LocalRunner: real response path ───────────────────────────────────────

    #[tokio::test]
    async fn local_runner_resolves_known_intent_with_real_response() {
        // This test FAILS against the old always-empty stub: it asserts a real
        // resolved intent + non-empty speech, which the stub never produced.
        let mut runner = LocalRunner::new(turn_on_recognizer().await);
        runner.spawn(RufloRunnerOpts::default()).await.unwrap();

        let resp = runner
            .send_request(serde_json::json!({
                "utterance": "turn on the kitchen light",
                "language": "en"
            }))
            .await
            .unwrap();

        let intent = resp.intent.expect("known intent must resolve to Some");
        assert_eq!(intent.name.as_str(), "HassTurnOn");
        assert!(intent.slots.contains_key("entity_id"));
        let speech = resp.speech.expect("a real response must carry speech");
        assert!(
            speech.to_lowercase().contains("turned on"),
            "speech should acknowledge the action, got {speech:?}"
        );
    }

    #[tokio::test]
    async fn local_runner_dotted_entity_round_trips() {
        let mut runner = LocalRunner::new(turn_on_recognizer().await);
        runner.spawn(RufloRunnerOpts::default()).await.unwrap();
        let resp = runner
            .send_request(serde_json::json!({"utterance": "turn on light.kitchen", "language": "en"}))
            .await
            .unwrap();
        let intent = resp.intent.expect("must resolve");
        assert_eq!(intent.entity_id(), Some("light.kitchen"));
        assert_eq!(resp.speech.as_deref(), Some("Turned on light.kitchen."));
    }

    #[tokio::test]
    async fn local_runner_unknown_utterance_falls_through() {
        let mut runner = LocalRunner::new(turn_on_recognizer().await);
        runner.spawn(RufloRunnerOpts::default()).await.unwrap();
        let resp = runner
            .send_request(serde_json::json!({"utterance": "play jazz music", "language": "en"}))
            .await
            .unwrap();
        assert!(resp.intent.is_none(), "unknown utterance must not resolve");
        assert!(resp.speech.is_none());
    }

    #[tokio::test]
    async fn local_runner_missing_utterance_is_typed_error() {
        let mut runner = LocalRunner::new(turn_on_recognizer().await);
        runner.spawn(RufloRunnerOpts::default()).await.unwrap();
        let err = runner
            .send_request(serde_json::json!({"language": "en"}))
            .await
            .unwrap_err();
        assert!(matches!(err, AssistError::ParseError(_)));
    }

    #[tokio::test]
    async fn local_runner_send_before_spawn_is_not_started() {
        let runner = LocalRunner::new(turn_on_recognizer().await);
        let err = runner
            .send_request(serde_json::json!({"utterance": "turn on light.kitchen"}))
            .await
            .unwrap_err();
        assert!(matches!(err, AssistError::NotStarted));
    }
}
