//! Small application-facing runtime for fetching compositions and reporting outcomes.

use std::{collections::HashMap, time::Duration};

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

#[derive(Debug, Error)]
pub enum RuntimeError {
    #[error("HTTP request failed: {0}")]
    Http(String),
    #[error("invalid platform response: {0}")]
    Json(#[from] serde_json::Error),
    #[error("execution response did not contain an ID")]
    MissingExecutionId,
}

pub type Result<T> = std::result::Result<T, RuntimeError>;

#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct Composition {
    #[serde(default, alias = "variants")]
    pub implementations: HashMap<String, String>,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Assignment {
    pub client_id: String,
    pub release_id: serde_json::Value,
    #[serde(alias = "generation")]
    pub revision: u64,
    pub implementations: HashMap<String, String>,
    #[serde(default)]
    pub reason: Option<String>,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CallTelemetry {
    pub spec_function: String,
    pub implementation_id: String,
    pub outcome: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub duration_ms: Option<f64>,
}

impl CallTelemetry {
    pub fn new(
        spec_function: impl Into<String>,
        implementation_id: impl Into<String>,
        outcome: impl Into<String>,
    ) -> Self {
        Self {
            spec_function: spec_function.into(),
            implementation_id: implementation_id.into(),
            outcome: outcome.into(),
            duration_ms: None,
        }
    }
}

#[derive(Clone, Debug, Serialize)]
pub struct TelemetryEvent {
    pub client_id: String,
    pub release_id: String,
    pub composition_revision: u64,
    pub event_type: String,
    pub spec_function: String,
    pub implementation_id: String,
    pub message_code: Option<String>,
    pub recent_calls: Vec<CallTelemetry>,
}

pub struct Client {
    base_url: String,
    agent: ureq::Agent,
    pub client_id: String,
    pub release_id: String,
    pub revision: u64,
    pub composition: Composition,
}

impl Client {
    pub fn register(
        base_url: &str,
        release_id: u64,
        installation_id: Option<&str>,
        strategy: &str,
    ) -> Result<Self> {
        let agent = ureq::AgentBuilder::new()
            .timeout(Duration::from_secs(10))
            .build();
        let base_url = base_url.trim_end_matches('/').to_owned();
        let assignment: Assignment = agent
            .post(&format!("{base_url}/api/v1/clients"))
            .send_json(serde_json::json!({
                "release_id": release_id,
                "installation_id": installation_id,
                "strategy": strategy
            }))
            .map_err(http_error)?
            .into_json()
            .map_err(|error| RuntimeError::Http(error.to_string()))?;
        Ok(Self::from_assignment(base_url, agent, assignment))
    }

    fn from_assignment(base_url: String, agent: ureq::Agent, assignment: Assignment) -> Self {
        Self {
            base_url,
            agent,
            client_id: assignment.client_id,
            release_id: json_scalar(&assignment.release_id),
            revision: assignment.revision,
            composition: Composition {
                implementations: assignment.implementations,
            },
        }
    }

    pub fn refresh(&mut self) -> Result<bool> {
        let assignment: Assignment = self
            .agent
            .get(&format!(
                "{}/api/v1/clients/{}/composition",
                self.base_url, self.client_id
            ))
            .call()
            .map_err(http_error)?
            .into_json()
            .map_err(|error| RuntimeError::Http(error.to_string()))?;
        let changed = assignment.revision != self.revision;
        self.release_id = json_scalar(&assignment.release_id);
        self.revision = assignment.revision;
        self.composition = Composition {
            implementations: assignment.implementations,
        };
        Ok(changed)
    }

    pub fn report_execution(
        &self,
        operation: &str,
        success: bool,
        duration_ms: f64,
        error_kind: Option<&str>,
        calls: &[CallTelemetry],
    ) -> Result<u64> {
        let value: serde_json::Value = self
            .agent
            .post(&format!("{}/api/v1/executions", self.base_url))
            .send_json(serde_json::json!({
                "client_id": self.client_id,
                "composition_revision": self.revision,
                "operation": operation,
                "success": success,
                "duration_ms": duration_ms,
                "error_kind": error_kind,
                "calls": calls
            }))
            .map_err(http_error)?
            .into_json()
            .map_err(|error| RuntimeError::Http(error.to_string()))?;
        value
            .get("execution_id")
            .and_then(serde_json::Value::as_u64)
            .ok_or(RuntimeError::MissingExecutionId)
    }

    pub fn report_event(&self, event: &TelemetryEvent) -> Result<()> {
        self.agent
            .post(&format!("{}/api/v1/telemetry", self.base_url))
            .send_json(event)
            .map_err(http_error)?;
        Ok(())
    }
}

fn json_scalar(value: &serde_json::Value) -> String {
    value
        .as_str()
        .map(str::to_owned)
        .unwrap_or_else(|| value.to_string())
}

fn http_error(error: ureq::Error) -> RuntimeError {
    match error {
        ureq::Error::Status(code, response) => RuntimeError::Http(format!(
            "server returned {code}: {}",
            response.into_string().unwrap_or_default()
        )),
        other => RuntimeError::Http(other.to_string()),
    }
}

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

    #[test]
    fn telemetry_event_contains_metadata_only() {
        let value = serde_json::to_value(TelemetryEvent {
            client_id: "pc_12345678".into(),
            release_id: "1".into(),
            composition_revision: 2,
            event_type: "integrity_failure".into(),
            spec_function: "validate_crc".into(),
            implementation_id: "validate_crc.chunked".into(),
            message_code: Some("crc".into()),
            recent_calls: vec![CallTelemetry::new(
                "validate_crc",
                "validate_crc.chunked",
                "error",
            )],
        })
        .unwrap();
        assert!(value.get("archive").is_none());
        assert!(value.get("message").is_none());
        assert_eq!(value["recent_calls"][0]["outcome"], "error");
    }
}
