//! Rendering profile selection between Leptos hydration and HTMX server-side.

use std::sync::OnceLock;

use serde::{Deserialize, Serialize};

/// Identifies the client interactivity strategy used to render a route.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum RenderingProfile {
    /// Leptos SSR + WASM hydration on the client. Default for backward
    /// compatibility.
    #[default]
    LeptosHydration,
    /// Server-side rendering with HTMX attributes; no WASM bundle shipped.
    HtmxSsr,
}

impl RenderingProfile {
    /// Returns true when the profile does not require a `wasm32` build target.
    pub const fn skips_wasm(self) -> bool {
        matches!(self, Self::HtmxSsr)
    }

    /// Returns true when the profile requires the htmx client runtime asset.
    pub const fn requires_htmx_assets(self) -> bool {
        matches!(self, Self::HtmxSsr)
    }

    /// Kebab-case string representation matching the serde encoding.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::LeptosHydration => "leptos-hydration",
            Self::HtmxSsr => "htmx-ssr",
        }
    }
}

impl std::str::FromStr for RenderingProfile {
    type Err = UnknownRenderingProfile;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            "leptos-hydration" => Ok(Self::LeptosHydration),
            "htmx-ssr" => Ok(Self::HtmxSsr),
            other => Err(UnknownRenderingProfile(other.to_owned())),
        }
    }
}

impl std::fmt::Display for RenderingProfile {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Error returned when a string cannot be parsed as a known
/// [`RenderingProfile`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownRenderingProfile(pub String);

impl std::fmt::Display for UnknownRenderingProfile {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "unknown rendering profile `{}` (expected `leptos-hydration` or `htmx-ssr`)",
            self.0
        )
    }
}

impl std::error::Error for UnknownRenderingProfile {}

/// Where the content graph sidebar is rendered on static project/product pages.
///
/// Posts always render the graph as a side column. Static pages (e.g.
/// `/rustelo`) can place it at the side (same grid as posts), at the bottom of
/// the content, or disabled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ContentGraphPosition {
    /// Right sidebar column, same grid as content posts (default).
    #[default]
    Side,
    /// Below the page content as a full-width section.
    Bottom,
    /// Graph not rendered on static pages.
    None,
}

/// Workspace-wide rendering settings parsed from the `[rendering]` table.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RenderingConfig {
    /// Workspace-wide default applied to any route that does not declare its
    /// own profile.
    pub default_profile: RenderingProfile,
    /// Set of extension `name`s from the htmx lockfile that should be loaded in
    /// `htmx-ssr` shells.
    pub htmx_extensions: Vec<String>,
    /// Graph position on static project/product pages. Posts always use `Side`.
    pub content_graph_position: ContentGraphPosition,
}

static WORKSPACE_RENDERING_CONFIG: OnceLock<RenderingConfig> = OnceLock::new();

/// Stores the workspace rendering configuration; subsequent calls are ignored.
///
/// Intended to be called once during application startup from the loaded site
/// config.
pub fn set_workspace_rendering_config(config: RenderingConfig) {
    let _ = WORKSPACE_RENDERING_CONFIG.set(config);
}

/// Returns the workspace rendering configuration, falling back to
/// [`RenderingConfig::default`].
pub fn workspace_rendering_config() -> &'static RenderingConfig {
    WORKSPACE_RENDERING_CONFIG.get_or_init(RenderingConfig::default)
}

/// Shortcut for the workspace-wide default profile.
pub fn workspace_rendering_profile() -> RenderingProfile {
    workspace_rendering_config().default_profile
}

/// Returns the effective profile for a route, applying the precedence rule:
/// route override > workspace default > [`RenderingProfile::default`].
pub fn resolve_rendering_profile(route_override: Option<RenderingProfile>) -> RenderingProfile {
    route_override.unwrap_or_else(workspace_rendering_profile)
}

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

    #[test]
    fn default_is_leptos_hydration() {
        assert_eq!(
            RenderingProfile::default(),
            RenderingProfile::LeptosHydration
        );
    }

    #[test]
    fn skips_wasm_only_for_htmx() {
        assert!(!RenderingProfile::LeptosHydration.skips_wasm());
        assert!(RenderingProfile::HtmxSsr.skips_wasm());
    }

    #[test]
    fn requires_htmx_assets_only_for_htmx() {
        assert!(!RenderingProfile::LeptosHydration.requires_htmx_assets());
        assert!(RenderingProfile::HtmxSsr.requires_htmx_assets());
    }

    #[test]
    fn serde_roundtrip_kebab_case() {
        let cases = [
            (RenderingProfile::LeptosHydration, "\"leptos-hydration\""),
            (RenderingProfile::HtmxSsr, "\"htmx-ssr\""),
        ];
        for (variant, encoded) in cases {
            let s = serde_json::to_string(&variant).expect("serialize");
            assert_eq!(s, encoded);
            let parsed: RenderingProfile = serde_json::from_str(&s).expect("deserialize");
            assert_eq!(parsed, variant);
        }
    }

    #[test]
    fn from_str_accepts_canonical_forms() {
        assert_eq!(
            "leptos-hydration"
                .parse::<RenderingProfile>()
                .expect("canonical leptos-hydration string parses"),
            RenderingProfile::LeptosHydration
        );
        assert_eq!(
            "htmx-ssr"
                .parse::<RenderingProfile>()
                .expect("canonical htmx-ssr string parses"),
            RenderingProfile::HtmxSsr
        );
    }

    #[test]
    fn from_str_rejects_unknown() {
        let err = "react-ssr"
            .parse::<RenderingProfile>()
            .expect_err("unknown rendering profile must return error");
        assert_eq!(err.0, "react-ssr");
    }

    #[test]
    fn display_matches_serde_encoding() {
        assert_eq!(
            RenderingProfile::LeptosHydration.to_string(),
            "leptos-hydration"
        );
        assert_eq!(RenderingProfile::HtmxSsr.to_string(), "htmx-ssr");
    }

    #[test]
    fn rendering_config_defaults_to_hydration_and_empty_extensions() {
        let cfg = RenderingConfig::default();
        assert_eq!(cfg.default_profile, RenderingProfile::LeptosHydration);
        assert!(cfg.htmx_extensions.is_empty());
    }

    #[test]
    fn rendering_config_parses_toml_with_partial_table() {
        let toml = r#"
            default_profile = "htmx-ssr"
            htmx_extensions = ["response-targets", "preload"]
        "#;
        let cfg: RenderingConfig = toml::from_str(toml).expect("parse");
        assert_eq!(cfg.default_profile, RenderingProfile::HtmxSsr);
        assert_eq!(cfg.htmx_extensions, vec!["response-targets", "preload"]);
    }

    #[test]
    fn rendering_config_parses_empty_table() {
        let cfg: RenderingConfig = toml::from_str("").expect("parse");
        assert_eq!(cfg, RenderingConfig::default());
    }

    #[test]
    fn resolve_uses_route_override_when_present() {
        assert_eq!(
            resolve_rendering_profile(Some(RenderingProfile::HtmxSsr)),
            RenderingProfile::HtmxSsr
        );
        assert_eq!(
            resolve_rendering_profile(Some(RenderingProfile::LeptosHydration)),
            RenderingProfile::LeptosHydration
        );
    }

    #[test]
    fn resolve_falls_back_to_workspace_default_when_none() {
        // OnceLock state is shared across tests; without explicit setup we get the type
        // default.
        let workspace = workspace_rendering_profile();
        assert_eq!(resolve_rendering_profile(None), workspace);
    }
}
