//! `similar` matcher: embedding cosine similarity to a reference.
//!
//! Why this exists: `levenshtein` answers "is the text close character by
//! character?" but two answers can be worded differently and still mean the
//! same thing. `similar` embeds the reference and the actual value with an
//! embedding model the user points at (their own key, per-run, stateless) and
//! passes when the cosine similarity clears a threshold.
//!
//! Like `llm-judge`, this is an async matcher: it needs a provider call. It is
//! wired through the executor's async path, not the synchronous `check_matcher`
//! dispatch, and it is not composable under the `not:` wrapper.

use serde::Deserialize;
use serde_json::Value;

use super::result::{AssertionDiff, AssertionResult, MatcherKind};
use crate::eval::provider::LlmProvider;
use crate::eval::ProviderError;
use crate::Error;

/// Parsed `similar:` YAML block.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SimilarConfig {
    /// Reference string the actual value is compared against.
    pub value: String,
    /// Cosine-similarity floor in `[0, 1]`. The assertion passes when the
    /// similarity is at least this value.
    pub threshold: f64,
    /// Embedding model identifier passed to the provider.
    pub model: String,
}

/// Parse a raw matcher value into a typed [`SimilarConfig`].
///
/// Returns [`Error::InvalidMatcherInput`] when the block does not match the
/// schema, so the executor folds it like every other matcher error.
pub fn parse_config(value: &Value) -> crate::Result<SimilarConfig> {
    let config: SimilarConfig = serde_json::from_value(value.clone())
        .map_err(|e| Error::InvalidMatcherInput(format!("invalid similar block: {e}")))?;
    if !(0.0..=1.0).contains(&config.threshold) {
        return Err(Error::InvalidMatcherInput(
            "similar `threshold` must be in [0, 1]".into(),
        ));
    }
    Ok(config)
}

/// Embed the reference and the actual value, then compare by cosine.
///
/// The actual value is stringified (a JSON string uses its raw text; any other
/// JSON value uses its compact serialization). Returns a passing or failing
/// [`AssertionResult`]; a provider failure (including an unsupported-embeddings
/// provider) propagates as a [`ProviderError`].
pub async fn evaluate(
    config: &SimilarConfig,
    actual: &Value,
    provider: &dyn LlmProvider,
) -> Result<AssertionResult, ProviderError> {
    let candidate = stringify(actual);
    let reference_vec = provider.embed(&config.value, &config.model).await?;
    let candidate_vec = provider.embed(&candidate, &config.model).await?;
    let score = match cosine_similarity(&reference_vec, &candidate_vec) {
        Some(score) => score,
        None => {
            return Ok(failed(
                config.threshold,
                None,
                "embedding vectors had mismatched or zero length",
            ))
        }
    };
    if score >= config.threshold {
        Ok(AssertionResult::passed_with_note(
            MatcherKind::Similar,
            format!("cosine {score:.4} >= threshold {:.4}", config.threshold),
        ))
    } else {
        Ok(failed(
            config.threshold,
            Some(score),
            "cosine similarity below threshold",
        ))
    }
}

/// Stringify the value at the target for embedding. Strings pass through
/// verbatim; other values fall back to their compact JSON form.
fn stringify(value: &Value) -> String {
    match value {
        Value::String(s) => s.clone(),
        other => other.to_string(),
    }
}

/// Build the failure diff, optionally carrying the computed score in its note.
fn failed(threshold: f64, score: Option<f64>, note: &str) -> AssertionResult {
    let note = match score {
        Some(s) => format!("{note}: cosine {s:.4} < threshold {threshold:.4}"),
        None => note.to_string(),
    };
    AssertionResult::failed(
        MatcherKind::Similar,
        AssertionDiff {
            path: String::new(),
            expected: None,
            actual: None,
            note: Some(note),
        },
    )
}

/// Cosine similarity of two equal-length vectors. Returns `None` when the
/// lengths differ or either vector has zero magnitude (cosine is undefined).
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> Option<f64> {
    if a.is_empty() || a.len() != b.len() {
        return None;
    }
    let mut dot = 0.0f64;
    let mut norm_a = 0.0f64;
    let mut norm_b = 0.0f64;
    for (x, y) in a.iter().zip(b.iter()) {
        let (x, y) = (f64::from(*x), f64::from(*y));
        dot += x * y;
        norm_a += x * x;
        norm_b += y * y;
    }
    if norm_a == 0.0 || norm_b == 0.0 {
        return None;
    }
    Some(dot / (norm_a.sqrt() * norm_b.sqrt()))
}

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

    use crate::eval::provider::{
        Capabilities, CompletionOptions, Cost, Message, ProviderId, Response, Usage,
    };

    // Deterministic embedding provider for tests: a character histogram so
    // identical strings embed identically (cosine 1.0) and different strings
    // diverge. No network.
    struct HistogramProvider;

    fn histogram(text: &str) -> Vec<f32> {
        let mut v = [0.0f32; 16];
        for ch in text.chars() {
            v[(ch as usize) % 16] += 1.0;
        }
        v.to_vec()
    }

    #[async_trait]
    impl LlmProvider for HistogramProvider {
        fn name(&self) -> ProviderId {
            ProviderId::Openai
        }
        fn supported_capabilities(&self) -> Capabilities {
            Capabilities::none()
        }
        async fn chat_completion(
            &self,
            _messages: &[Message],
            _options: &CompletionOptions,
        ) -> Result<Response, ProviderError> {
            Err(ProviderError::ProviderInternal("not used".into()))
        }
        async fn count_tokens(&self, _text: &str, _model: &str) -> Result<u32, ProviderError> {
            Ok(0)
        }
        async fn embed(&self, text: &str, _model: &str) -> Result<Vec<f32>, ProviderError> {
            Ok(histogram(text))
        }
        fn estimate_cost(&self, _usage: &Usage, _model: &str) -> Cost {
            Cost::default()
        }
    }

    #[test]
    fn cosine_of_identical_vectors_is_one() {
        let v = vec![1.0, 2.0, 3.0];
        let s = cosine_similarity(&v, &v).unwrap();
        assert!((s - 1.0).abs() < 1e-9, "got {s}");
    }

    #[test]
    fn cosine_of_orthogonal_vectors_is_zero() {
        let s = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]).unwrap();
        assert!(s.abs() < 1e-9, "got {s}");
    }

    #[test]
    fn cosine_rejects_mismatched_lengths() {
        assert!(cosine_similarity(&[1.0, 2.0], &[1.0]).is_none());
    }

    #[test]
    fn cosine_rejects_zero_vector() {
        assert!(cosine_similarity(&[0.0, 0.0], &[1.0, 2.0]).is_none());
    }

    fn config(threshold: f64) -> SimilarConfig {
        SimilarConfig {
            value: "the quick brown fox".into(),
            threshold,
            model: "text-embedding-3-small".into(),
        }
    }

    #[tokio::test]
    async fn similar_passes_on_identical_text() {
        let r = evaluate(
            &config(0.99),
            &json!("the quick brown fox"),
            &HistogramProvider,
        )
        .await
        .unwrap();
        assert!(r.pass, "diffs: {:?}", r.diffs);
        assert_eq!(r.kind, MatcherKind::Similar);
    }

    #[tokio::test]
    async fn similar_fails_when_below_threshold() {
        let r = evaluate(&config(0.99), &json!("zzz qqq vvv"), &HistogramProvider)
            .await
            .unwrap();
        assert!(!r.pass);
        assert!(r.diffs[0].note.as_deref().unwrap().contains("threshold"));
    }

    #[tokio::test]
    async fn similar_propagates_unsupported_provider_error() {
        struct NoEmbed;
        #[async_trait]
        impl LlmProvider for NoEmbed {
            fn name(&self) -> ProviderId {
                ProviderId::Anthropic
            }
            fn supported_capabilities(&self) -> Capabilities {
                Capabilities::none()
            }
            async fn chat_completion(
                &self,
                _m: &[Message],
                _o: &CompletionOptions,
            ) -> Result<Response, ProviderError> {
                Err(ProviderError::ProviderInternal("nope".into()))
            }
            async fn count_tokens(&self, _t: &str, _m: &str) -> Result<u32, ProviderError> {
                Ok(0)
            }
            async fn embed(&self, _t: &str, _m: &str) -> Result<Vec<f32>, ProviderError> {
                Err(ProviderError::FeatureUnsupported {
                    provider: "anthropic",
                    feature: "embeddings",
                })
            }
            fn estimate_cost(&self, _u: &Usage, _m: &str) -> Cost {
                Cost::default()
            }
        }
        let err = evaluate(&config(0.5), &json!("x"), &NoEmbed)
            .await
            .unwrap_err();
        assert!(matches!(err, ProviderError::FeatureUnsupported { .. }));
    }

    #[test]
    fn parse_config_rejects_out_of_range_threshold() {
        let v = json!({"value": "x", "threshold": 1.5, "model": "m"});
        assert!(matches!(
            parse_config(&v),
            Err(Error::InvalidMatcherInput(_))
        ));
    }

    #[test]
    fn parse_config_rejects_unknown_field() {
        let v = json!({"value": "x", "threshold": 0.5, "model": "m", "oops": 1});
        assert!(matches!(
            parse_config(&v),
            Err(Error::InvalidMatcherInput(_))
        ));
    }
}
