//! JSON-LD structured data builders.
//!
//! Renders `<script type="application/ld+json">` blocks. Provides builders for
//! the most common schema.org types used by content sites: Article, Person,
//! Organization, BreadcrumbList, WebSite, FAQPage.
//!
//! Defaults are produced per content kind by the consumer; per-page overrides
//! come from content frontmatter and are merged before rendering.

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

/// A single JSON-LD block emitted as a `<script>` tag.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonLdBlock {
    /// Schema.org `@type` value driving the block's shape.
    pub schema: JsonLdSchema,
    /// Free-form properties merged into the final JSON-LD object. Overrides
    /// any defaults produced by `schema.default_properties()`.
    pub properties: Value,
}

impl JsonLdBlock {
    /// Build a block from a schema and a single JSON value of properties.
    pub fn new(schema: JsonLdSchema, properties: Value) -> Self {
        Self { schema, properties }
    }

    /// Merge `self.properties` over the schema's defaults and return the
    /// final JSON object ready for serialization.
    fn merged(&self) -> Value {
        let mut base = self.schema.default_properties();
        if let (Some(base_obj), Some(extra)) = (base.as_object_mut(), self.properties.as_object()) {
            for (k, v) in extra {
                base_obj.insert(k.clone(), v.clone());
            }
        }
        base
    }
}

/// Supported schema.org types. Extend as needed; each variant carries its
/// own default property set so callers only need to provide the overrides.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum JsonLdSchema {
    Article,
    BlogPosting,
    Person,
    Organization,
    BreadcrumbList,
    WebSite,
    FaqPage,
    Recipe,
    SoftwareSourceCode,
}

impl JsonLdSchema {
    /// schema.org `@type` literal.
    pub fn type_name(self) -> &'static str {
        match self {
            JsonLdSchema::Article => "Article",
            JsonLdSchema::BlogPosting => "BlogPosting",
            JsonLdSchema::Person => "Person",
            JsonLdSchema::Organization => "Organization",
            JsonLdSchema::BreadcrumbList => "BreadcrumbList",
            JsonLdSchema::WebSite => "WebSite",
            JsonLdSchema::FaqPage => "FAQPage",
            JsonLdSchema::Recipe => "Recipe",
            JsonLdSchema::SoftwareSourceCode => "SoftwareSourceCode",
        }
    }

    /// Default property set the consumer may override per-page.
    pub fn default_properties(self) -> Value {
        json!({
            "@context": "https://schema.org",
            "@type": self.type_name(),
        })
    }
}

/// Render all JSON-LD blocks as `<script type="application/ld+json">` tags.
///
/// # Errors
///
/// Returns [`SeoError::JsonSerialization`](crate::SeoError) if any block fails
/// to serialize.
pub fn render_jsonld_blocks(blocks: &[JsonLdBlock]) -> crate::Result<String> {
    let mut out = String::new();
    for block in blocks {
        let merged = block.merged();
        let json = serde_json::to_string(&merged)?;
        // Escape `</` to avoid premature `</script>` parsing in HTML.
        let safe = json.replace("</", "<\\/");
        out.push_str(&format!(
            r#"<script type="application/ld+json">{safe}</script>"#
        ));
    }
    Ok(out)
}

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

    #[test]
    fn renders_article_with_defaults_and_overrides() {
        let block = JsonLdBlock::new(
            JsonLdSchema::Article,
            json!({
                "headline": "Hello",
                "author": { "@type": "Person", "name": "Jesús" }
            }),
        );
        let html = render_jsonld_blocks(&[block]).expect("serialize");
        assert!(html.contains(r#"type="application/ld+json""#));
        assert!(html.contains(r#""@context":"https://schema.org""#));
        assert!(html.contains(r#""@type":"Article""#));
        assert!(html.contains(r#""headline":"Hello""#));
    }

    #[test]
    fn escapes_closing_script_in_payload() {
        let block = JsonLdBlock::new(
            JsonLdSchema::Article,
            json!({ "description": "evil </script> string" }),
        );
        let html = render_jsonld_blocks(&[block]).expect("serialize");
        assert!(html.contains(r"<\/script>"));
        // Only the outer </script> closes the tag.
        assert_eq!(html.matches("</script>").count(), 1);
    }

    #[test]
    fn supports_multiple_blocks() {
        let a = JsonLdBlock::new(JsonLdSchema::Person, json!({"name": "Jesús"}));
        let b = JsonLdBlock::new(JsonLdSchema::BreadcrumbList, json!({"itemListElement": []}));
        let html = render_jsonld_blocks(&[a, b]).expect("serialize");
        assert!(html.contains("Person"));
        assert!(html.contains("BreadcrumbList"));
    }

    #[test]
    fn override_takes_precedence_over_default() {
        let block = JsonLdBlock::new(JsonLdSchema::Article, json!({ "@type": "TechArticle" }));
        let html = render_jsonld_blocks(&[block]).expect("serialize");
        assert!(html.contains(r#""@type":"TechArticle""#));
        assert!(!html.contains(r#""@type":"Article""#));
    }
}
