use std::time::Duration;

use serde_json::Value;

use crate::config::AiCapability;
use crate::daemon_url::daemon_url;

const PROBE_TIMEOUT: Duration = Duration::from_millis(750);
const GENERIC_ENABLED_PATH: &[&str] = &["enabled"];

const PROBED_CAPABILITIES: [AiCapability; 5] = [
    AiCapability::Embed,
    AiCapability::AudioTranscribe,
    AiCapability::AudioTranslate,
    AiCapability::VisionExtract,
    AiCapability::TextGenerate,
];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CapabilityStatusRoute {
    pub method: &'static str,
    pub path: &'static str,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapabilityDegradationReason {
    MissingStatusRoute,
    MissingEndpoint,
    Unauthorized,
    UnexpectedStatus,
    Unreachable,
    NotAdvertised,
    InvalidStatusBody,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapabilityDegradation {
    pub capability: AiCapability,
    pub reason: CapabilityDegradationReason,
    pub message: String,
    pub http_status: Option<u16>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapabilityAvailability {
    pub capability: AiCapability,
    pub available: bool,
    pub status_route: Option<CapabilityStatusRoute>,
    pub degradation: Option<CapabilityDegradation>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapabilityProbeReport {
    pub base_url: String,
    pub capabilities: Vec<CapabilityAvailability>,
}

impl CapabilityProbeReport {
    pub fn availability(&self, capability: AiCapability) -> Option<&CapabilityAvailability> {
        self.capabilities
            .iter()
            .find(|availability| availability.capability == capability)
    }
}

pub fn capability_status_route(capability: AiCapability) -> CapabilityStatusRoute {
    let path = match capability {
        AiCapability::AudioTranscribe | AiCapability::AudioTranslate => "/api/voice/status",
        AiCapability::VisionExtract => "/api/llm/vision/status",
        // ToolChat shares the LLM status route; the body advertises tool-call
        // support under its own `tool_chat` key (see `status_body_advertises`).
        AiCapability::TextGenerate | AiCapability::ToolChat => "/api/llm/status",
        AiCapability::Embed => "/api/embeddings/status",
    };

    CapabilityStatusRoute {
        method: "GET",
        path,
    }
}

pub fn probe_daemon_capability(capability: AiCapability) -> CapabilityAvailability {
    probe_daemon_capability_at(&daemon_url(), capability)
}

pub fn probe_daemon_capability_at(
    base_url: &str,
    capability: AiCapability,
) -> CapabilityAvailability {
    probe_daemon_capability_with(base_url, capability, &UreqProbeTransport)
}

pub fn probe_daemon_capabilities() -> CapabilityProbeReport {
    probe_daemon_capabilities_at(&daemon_url())
}

pub fn probe_daemon_capabilities_at(base_url: &str) -> CapabilityProbeReport {
    probe_daemon_capabilities_with(base_url, &UreqProbeTransport)
}

fn probe_daemon_capabilities_with(
    base_url: &str,
    transport: &impl DaemonProbeTransport,
) -> CapabilityProbeReport {
    CapabilityProbeReport {
        base_url: base_url.to_string(),
        capabilities: PROBED_CAPABILITIES
            .into_iter()
            .map(|capability| probe_daemon_capability_with(base_url, capability, transport))
            .collect(),
    }
}

fn probe_daemon_capability_with(
    base_url: &str,
    capability: AiCapability,
    transport: &impl DaemonProbeTransport,
) -> CapabilityAvailability {
    let route = capability_status_route(capability);

    match transport.status(base_url, route.method, route.path) {
        ProbeObservation::Http { status, body } if (200..=299).contains(&status) => {
            match status_body_advertises(capability, body.as_deref()) {
                Ok(true) => CapabilityAvailability {
                    capability,
                    available: true,
                    status_route: Some(route),
                    degradation: None,
                },
                Ok(false) => unavailable(
                    capability,
                    Some(route),
                    CapabilityDegradationReason::NotAdvertised,
                    "daemon status route does not advertise this capability",
                    Some(status),
                ),
                Err(message) => unavailable(
                    capability,
                    Some(route),
                    CapabilityDegradationReason::InvalidStatusBody,
                    message,
                    Some(status),
                ),
            }
        }
        ProbeObservation::Http {
            status: status @ (401 | 403),
            ..
        } => unavailable(
            capability,
            Some(route),
            CapabilityDegradationReason::Unauthorized,
            "daemon status route requires authorization for this process",
            Some(status),
        ),
        ProbeObservation::Http { status: 404, .. } => unavailable(
            capability,
            Some(route),
            CapabilityDegradationReason::MissingEndpoint,
            "daemon status route is not registered",
            Some(404),
        ),
        ProbeObservation::Http { status, .. } => unavailable(
            capability,
            Some(route),
            CapabilityDegradationReason::UnexpectedStatus,
            format!("daemon status route returned HTTP {status}"),
            Some(status),
        ),
        ProbeObservation::TransportError(message) => unavailable(
            capability,
            Some(route),
            CapabilityDegradationReason::Unreachable,
            format!("daemon status route failed: {message}"),
            None,
        ),
    }
}

fn status_body_advertises(capability: AiCapability, body: Option<&str>) -> Result<bool, String> {
    let body = body
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "daemon status route returned an empty body".to_string())?;
    let value: Value = serde_json::from_str(body)
        .map_err(|error| format!("daemon status body is not valid JSON: {error}"))?;

    let paths: &[&[&str]] = match capability {
        AiCapability::AudioTranscribe => &[
            &["transcription_enabled"],
            &["openai_compatible_audio", "transcription_enabled"],
            &["voice", "openai_compatible_audio", "transcription_enabled"],
        ],
        AiCapability::AudioTranslate => &[
            &["translation_enabled"],
            &["openai_compatible_audio", "translation_enabled"],
            &["voice", "openai_compatible_audio", "translation_enabled"],
        ],
        AiCapability::VisionExtract => &[
            &["vision_extract"],
            &["vision_extract_enabled"],
            &["extraction_enabled"],
            &["capabilities", "vision_extract"],
            &["enabled"],
        ],
        AiCapability::TextGenerate => &[
            &["text_generate"],
            &["text_generate_enabled"],
            &["generation_enabled"],
            &["capabilities", "text_generate"],
            &["enabled"],
        ],
        // Tool-call support must be advertised explicitly: a daemon with text
        // generation enabled does NOT imply tool calling. The generic `enabled`
        // fallback is suppressed for ToolChat below so "LLM is on" can never
        // satisfy tool_chat — only an explicit tool_chat truth does.
        AiCapability::ToolChat => &[
            &["tool_chat"],
            &["tool_chat_enabled"],
            &["capabilities", "tool_chat"],
        ],
        AiCapability::Embed => &[
            &["embed"],
            &["embedding_enabled"],
            &["embeddings_enabled"],
            &["capabilities", "embed"],
            &["enabled"],
        ],
    };

    // ToolChat requires an explicit tool_chat truth; every other capability may
    // fall back to a generic top-level `enabled` flag.
    let generic = (!matches!(capability, AiCapability::ToolChat)).then_some(GENERIC_ENABLED_PATH);
    let mut advertised = false;
    for path in paths.iter().copied().chain(generic) {
        if let Some(enabled) = bool_at_path(&value, path) {
            advertised = true;
            if enabled {
                return Ok(true);
            }
        }
    }
    if advertised {
        Ok(false)
    } else {
        Err(format!(
            "daemon status body does not advertise {}",
            capability.as_str()
        ))
    }
}

fn bool_at_path(value: &Value, path: &[&str]) -> Option<bool> {
    path.iter()
        .try_fold(value, |current, key| current.get(*key))
        .and_then(|value| {
            value
                .as_bool()
                .or_else(|| value.get("available").and_then(Value::as_bool))
        })
}

fn unavailable(
    capability: AiCapability,
    status_route: Option<CapabilityStatusRoute>,
    reason: CapabilityDegradationReason,
    message: impl Into<String>,
    http_status: Option<u16>,
) -> CapabilityAvailability {
    CapabilityAvailability {
        capability,
        available: false,
        status_route,
        degradation: Some(CapabilityDegradation {
            capability,
            reason,
            message: message.into(),
            http_status,
        }),
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum ProbeObservation {
    Http { status: u16, body: Option<String> },
    TransportError(String),
}

trait DaemonProbeTransport {
    fn status(&self, base_url: &str, method: &str, path: &str) -> ProbeObservation;
}

struct UreqProbeTransport;

impl DaemonProbeTransport for UreqProbeTransport {
    fn status(&self, base_url: &str, method: &str, path: &str) -> ProbeObservation {
        let url = format!("{}{}", base_url.trim_end_matches('/'), path);
        match ureq::request(method, &url).timeout(PROBE_TIMEOUT).call() {
            Ok(response) => ProbeObservation::Http {
                status: response.status(),
                body: response.into_string().ok(),
            },
            Err(ureq::Error::Status(status, response)) => ProbeObservation::Http {
                status,
                body: response.into_string().ok(),
            },
            Err(error) => ProbeObservation::TransportError(error.to_string()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;
    use std::collections::HashMap;

    #[test]
    fn capability_status_routes() {
        assert_eq!(
            capability_status_route(AiCapability::VisionExtract).path,
            "/api/llm/vision/status"
        );
        assert_eq!(
            capability_status_route(AiCapability::AudioTranscribe).path,
            "/api/voice/status"
        );
        assert_eq!(
            capability_status_route(AiCapability::AudioTranslate).path,
            "/api/voice/status"
        );
        assert_eq!(
            capability_status_route(AiCapability::TextGenerate).path,
            "/api/llm/status"
        );
        assert_eq!(
            capability_status_route(AiCapability::Embed).path,
            "/api/embeddings/status"
        );

        let report = probe_daemon_capabilities_with("http://daemon.test", &FakeTransport::new([]));
        assert!(
            report
                .availability(AiCapability::VisionExtract)
                .is_some_and(|availability| !availability.available)
        );
        assert!(
            report
                .availability(AiCapability::AudioTranscribe)
                .is_some_and(|availability| !availability.available)
        );
        assert!(
            report
                .availability(AiCapability::AudioTranslate)
                .is_some_and(|availability| !availability.available)
        );
        assert!(
            report
                .availability(AiCapability::TextGenerate)
                .is_some_and(|availability| !availability.available)
        );
        assert!(report.availability(AiCapability::Embed).is_some());
        assert!(
            CapabilityProbeReport {
                base_url: "http://daemon.test".to_string(),
                capabilities: Vec::new(),
            }
            .availability(AiCapability::Embed)
            .is_none()
        );
    }

    #[test]
    fn embed_status_body_requires_advertised_capability() {
        assert_eq!(
            status_body_advertises(AiCapability::Embed, Some(r#"{"embedding_enabled":true}"#)),
            Ok(true)
        );
        assert_eq!(
            status_body_advertises(
                AiCapability::Embed,
                Some(r#"{"embedding_enabled":false,"capabilities":{"embed":true}}"#)
            ),
            Ok(true)
        );
        assert!(status_body_advertises(AiCapability::Embed, Some(r#"{}"#)).is_err());
    }

    #[test]
    fn audio_status_body_accepts_generic_enabled_key() {
        assert_eq!(
            status_body_advertises(AiCapability::AudioTranscribe, Some(r#"{"enabled":true}"#)),
            Ok(true)
        );
        assert_eq!(
            status_body_advertises(AiCapability::AudioTranslate, Some(r#"{"enabled":false}"#)),
            Ok(false)
        );
    }

    #[test]
    fn tool_chat_shares_llm_status_route() {
        assert_eq!(
            capability_status_route(AiCapability::ToolChat).path,
            "/api/llm/status"
        );
    }

    #[test]
    fn tool_chat_requires_explicit_tool_capability_truth() {
        // Explicit tool_chat truth is honored, including the capabilities object
        // and the `{available: bool}` object form.
        assert_eq!(
            status_body_advertises(AiCapability::ToolChat, Some(r#"{"tool_chat":true}"#)),
            Ok(true)
        );
        assert_eq!(
            status_body_advertises(
                AiCapability::ToolChat,
                Some(r#"{"capabilities":{"tool_chat":true}}"#)
            ),
            Ok(true)
        );
        assert_eq!(
            status_body_advertises(
                AiCapability::ToolChat,
                Some(r#"{"tool_chat":{"available":true}}"#)
            ),
            Ok(true)
        );
        // Explicitly off is reported as unavailable, not an error.
        assert_eq!(
            status_body_advertises(AiCapability::ToolChat, Some(r#"{"tool_chat":false}"#)),
            Ok(false)
        );

        // A daemon with text generation enabled does NOT imply tool calling:
        // neither the generic `enabled` flag nor `text_generate` can satisfy
        // tool_chat, so the absence of an explicit tool_chat key is an error.
        assert!(
            status_body_advertises(AiCapability::ToolChat, Some(r#"{"enabled":true}"#)).is_err()
        );
        assert!(
            status_body_advertises(
                AiCapability::ToolChat,
                Some(r#"{"text_generate":true,"enabled":true}"#)
            )
            .is_err()
        );
    }

    #[test]
    fn attachments_not_vision_extraction() {
        let transport = FakeTransport::new([(
            ("POST", "/api/chat/attachments"),
            ProbeObservation::Http {
                status: 200,
                body: Some(r#"{"enabled":true}"#.to_string()),
            },
        )]);

        let report = probe_daemon_capabilities_with("http://daemon.test", &transport);

        assert!(
            report
                .availability(AiCapability::VisionExtract)
                .is_some_and(|availability| !availability.available)
        );
        assert!(
            !transport
                .requests()
                .contains(&("POST".to_string(), "/api/chat/attachments".to_string()))
        );
        assert!(
            transport
                .requests()
                .contains(&("GET".to_string(), "/api/llm/vision/status".to_string()))
        );
    }

    #[test]
    fn status_body_capability_truth() {
        let transport = FakeTransport::new([(
            ("GET", "/api/voice/status"),
            ProbeObservation::Http {
                status: 200,
                body: Some(
                    r#"{"transcription_enabled":true,"translation_enabled":false}"#.to_string(),
                ),
            },
        )]);

        let report = probe_daemon_capabilities_with("http://daemon.test", &transport);

        assert!(
            report
                .availability(AiCapability::AudioTranscribe)
                .is_some_and(|availability| availability.available)
        );
        assert!(
            report
                .availability(AiCapability::AudioTranslate)
                .is_some_and(|availability| !availability.available)
        );
    }

    #[test]
    fn status_body_accepts_capability_objects_with_available_field() {
        let transport = FakeTransport::new([(
            ("GET", "/api/llm/status"),
            ProbeObservation::Http {
                status: 200,
                body: Some(
                    r#"{"capabilities":{"text_generate":{"available":true,"state":"available"}}}"#
                        .to_string(),
                ),
            },
        )]);

        let report = probe_daemon_capability_with(
            "http://daemon.test",
            AiCapability::TextGenerate,
            &transport,
        );

        assert!(report.available);
    }

    #[test]
    fn status_route_is_availability_truth() {
        let transport = FakeTransport::new([(
            ("GET", "/api/providers/models"),
            ProbeObservation::Http {
                status: 200,
                body: Some(r#"{"capabilities":{"text_generate":true}}"#.to_string()),
            },
        )]);

        let report = probe_daemon_capabilities_with("http://daemon.test", &transport);

        assert!(
            report
                .availability(AiCapability::TextGenerate)
                .is_some_and(|availability| !availability.available)
        );
        assert!(
            !transport
                .requests()
                .contains(&("GET".to_string(), "/api/providers/models".to_string()))
        );
        assert!(
            transport
                .requests()
                .contains(&("GET".to_string(), "/api/llm/status".to_string()))
        );
    }

    struct FakeTransport {
        responses: HashMap<(&'static str, &'static str), ProbeObservation>,
        requests: RefCell<Vec<(String, String)>>,
    }

    impl FakeTransport {
        fn new(
            responses: impl IntoIterator<Item = ((&'static str, &'static str), ProbeObservation)>,
        ) -> Self {
            Self {
                responses: responses.into_iter().collect(),
                requests: RefCell::new(Vec::new()),
            }
        }

        fn requests(&self) -> Vec<(String, String)> {
            self.requests.borrow().clone()
        }
    }

    impl DaemonProbeTransport for FakeTransport {
        fn status(&self, _base_url: &str, method: &str, path: &str) -> ProbeObservation {
            self.requests
                .borrow_mut()
                .push((method.to_string(), path.to_string()));
            self.responses
                .get(&(method, path))
                .cloned()
                .unwrap_or(ProbeObservation::Http {
                    status: 404,
                    body: None,
                })
        }
    }
}
