//! Public data types for SEO rendering.
//!
//! [`PageSeo`] is the single input to every renderer in this crate. Callers
//! build it from their own route/content resolution and pass it to
//! [`render_head_tags`](crate::render_head_tags).

use serde::{Deserialize, Serialize};

/// Complete SEO descriptor for a single page.
///
/// All fields except `title` and `canonical` are optional or have safe
/// defaults. Construct one per request after resolving the route + content
/// frontmatter.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageSeo {
    pub title: String,
    pub description: Option<String>,
    pub keywords: Vec<String>,
    pub language: String,
    pub canonical: String,
    pub hreflang: Vec<HreflangAlt>,
    pub og: OpenGraph,
    pub twitter: TwitterCard,
    pub robots: RobotsDirective,
    #[cfg(feature = "jsonld")]
    pub jsonld: Vec<crate::JsonLdBlock>,
}

impl PageSeo {
    /// Build a minimal `PageSeo` with sensible defaults. Caller is expected to
    /// fill in OG image, JSON-LD blocks, and hreflang alternates per-page.
    pub fn new(
        title: impl Into<String>,
        canonical: impl Into<String>,
        language: impl Into<String>,
    ) -> Self {
        let title = title.into();
        let canonical = canonical.into();
        let language = language.into();
        Self {
            og: OpenGraph::new(&title, &canonical),
            twitter: TwitterCard::summary_large_image(&title),
            title,
            description: None,
            keywords: Vec::new(),
            language,
            canonical,
            hreflang: Vec::new(),
            robots: RobotsDirective::default(),
            #[cfg(feature = "jsonld")]
            jsonld: Vec::new(),
        }
    }
}

/// Alternate language URL declaration for `<link rel="alternate" hreflang>`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HreflangAlt {
    /// BCP-47 tag (e.g. "en", "es", "x-default").
    pub lang: String,
    /// Fully-qualified URL.
    pub href: String,
}

/// Open Graph descriptor. Renders as `<meta property="og:*">` tags.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenGraph {
    pub title: String,
    pub description: Option<String>,
    pub og_type: OgType,
    pub url: String,
    pub image: Option<OgImage>,
    pub site_name: Option<String>,
    pub locale: Option<String>,
}

impl OpenGraph {
    pub fn new(title: &str, url: &str) -> Self {
        Self {
            title: title.to_string(),
            description: None,
            og_type: OgType::Website,
            url: url.to_string(),
            image: None,
            site_name: None,
            locale: None,
        }
    }
}

/// Open Graph `og:type` values.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OgType {
    Website,
    Article,
    Profile,
    Book,
    #[serde(rename = "music.song")]
    MusicSong,
    #[serde(rename = "video.movie")]
    VideoMovie,
}

impl OgType {
    pub fn as_str(&self) -> &'static str {
        match self {
            OgType::Website => "website",
            OgType::Article => "article",
            OgType::Profile => "profile",
            OgType::Book => "book",
            OgType::MusicSong => "music.song",
            OgType::VideoMovie => "video.movie",
        }
    }
}

/// Open Graph image. Width/height are required by some crawlers.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OgImage {
    pub url: String,
    pub alt: Option<String>,
    pub width: Option<u32>,
    pub height: Option<u32>,
    pub mime_type: Option<String>,
}

impl OgImage {
    pub fn new(url: impl Into<String>) -> Self {
        Self {
            url: url.into(),
            alt: None,
            width: None,
            height: None,
            mime_type: None,
        }
    }
}

/// Twitter card descriptor. Renders as `<meta name="twitter:*">`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TwitterCard {
    pub card: TwitterCardKind,
    pub title: String,
    pub description: Option<String>,
    pub image: Option<String>,
    pub image_alt: Option<String>,
    pub site: Option<String>,
    pub creator: Option<String>,
}

impl TwitterCard {
    pub fn summary_large_image(title: &str) -> Self {
        Self {
            card: TwitterCardKind::SummaryLargeImage,
            title: title.to_string(),
            description: None,
            image: None,
            image_alt: None,
            site: None,
            creator: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TwitterCardKind {
    Summary,
    SummaryLargeImage,
    App,
    Player,
}

impl TwitterCardKind {
    pub fn as_str(&self) -> &'static str {
        match self {
            TwitterCardKind::Summary => "summary",
            TwitterCardKind::SummaryLargeImage => "summary_large_image",
            TwitterCardKind::App => "app",
            TwitterCardKind::Player => "player",
        }
    }
}

/// `<meta name="robots">` directive.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RobotsDirective {
    pub noindex: bool,
    pub nofollow: bool,
    pub noarchive: bool,
    pub nosnippet: bool,
    pub max_snippet: Option<i32>,
    pub max_image_preview: Option<MaxImagePreview>,
}

impl RobotsDirective {
    /// Returns the directive string (e.g. "index, follow") or `None` when all
    /// fields are default (no `<meta>` tag should be emitted).
    pub fn directive(&self) -> Option<String> {
        let mut parts: Vec<String> = Vec::new();
        if self.noindex {
            parts.push("noindex".into());
        }
        if self.nofollow {
            parts.push("nofollow".into());
        }
        if self.noarchive {
            parts.push("noarchive".into());
        }
        if self.nosnippet {
            parts.push("nosnippet".into());
        }
        if let Some(n) = self.max_snippet {
            parts.push(format!("max-snippet:{n}"));
        }
        if let Some(p) = &self.max_image_preview {
            parts.push(format!("max-image-preview:{}", p.as_str()));
        }
        if parts.is_empty() {
            None
        } else {
            Some(parts.join(", "))
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MaxImagePreview {
    None,
    Standard,
    Large,
}

impl MaxImagePreview {
    pub fn as_str(&self) -> &'static str {
        match self {
            MaxImagePreview::None => "none",
            MaxImagePreview::Standard => "standard",
            MaxImagePreview::Large => "large",
        }
    }
}
