//! Mission JSON-schema validation.
//!
//! Bundled copy of `shared/contracts/mission-schema.json` is compiled into the
//! binary via `include_str!`. The shared file is the wire contract co-owned
//! with the external `missions` repo (see `architecture.md §8 Q5`).

use std::sync::OnceLock;

use jsonschema::JSONSchema;
use serde_json::Value;

/// Bundled schema content (canonical wire contract).
pub const SCHEMA_BYTES: &str = include_str!("../../../../shared/contracts/mission-schema.json");

fn compiled() -> &'static JSONSchema {
    static SCHEMA: OnceLock<JSONSchema> = OnceLock::new();
    SCHEMA.get_or_init(|| {
        let schema_value: Value = serde_json::from_str(SCHEMA_BYTES)
            .expect("bundled mission-schema.json must be valid JSON at compile time");
        JSONSchema::options()
            .compile(&schema_value)
            .expect("bundled mission-schema.json must compile as JSON Schema")
    })
}

/// Validate raw JSON bytes against the bundled schema.
///
/// Returns the parsed JSON `Value` on success so callers can re-deserialise
/// it into the typed `Mission` without re-parsing.
pub fn validate(raw: &str) -> Result<Value, SchemaError> {
    let value: Value = serde_json::from_str(raw).map_err(|e| SchemaError::ParseJson {
        message: e.to_string(),
        sample: sample_of(raw),
    })?;

    let messages: Option<Vec<String>> = {
        let result = compiled().validate(&value);
        result
            .err()
            .map(|errors| errors.map(|e| format!("{e}")).collect())
    };

    if let Some(messages) = messages {
        return Err(SchemaError::Invalid {
            messages,
            sample: sample_of(raw),
        });
    }
    Ok(value)
}

const SAMPLE_CAP: usize = 1024;

fn sample_of(raw: &str) -> String {
    if raw.len() <= SAMPLE_CAP {
        raw.to_owned()
    } else {
        let mut s = raw[..SAMPLE_CAP].to_owned();
        s.push_str(" …<truncated>");
        s
    }
}

#[derive(Debug, thiserror::Error)]
pub enum SchemaError {
    #[error("response was not valid JSON: {message}")]
    ParseJson { message: String, sample: String },
    #[error("response failed schema validation: {}", messages.join("; "))]
    Invalid {
        messages: Vec<String>,
        sample: String,
    },
}

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

    const GOOD: &str = r#"{
        "mission_id": "11111111-2222-3333-4444-555555555555",
        "schema_version": "1.0.0",
        "items": [
            { "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "kind": "waypoint",
              "at": { "latitude": 49.1, "longitude": 31.2, "altitude_m": 100.0 } }
        ],
        "geofences": [],
        "return_point": { "latitude": 49.0, "longitude": 31.0, "altitude_m": 0.0 }
    }"#;

    #[test]
    fn good_mission_validates() {
        // Act
        let r = validate(GOOD);

        // Assert
        assert!(r.is_ok(), "validation failed: {:?}", r.err());
    }

    #[test]
    fn missing_required_field_fails() {
        // Arrange
        let bad = GOOD.replace("\"mission_id\"", "\"mission_oops\"");

        // Act
        let r = validate(&bad);

        // Assert
        assert!(matches!(r, Err(SchemaError::Invalid { .. })));
    }

    #[test]
    fn malformed_json_fails() {
        // Act
        let r = validate("{ not json");

        // Assert
        assert!(matches!(r, Err(SchemaError::ParseJson { .. })));
    }
}
